1
0
mirror of https://github.com/immich-app/immich.git synced 2024-11-24 08:52:28 +02:00

feat(web): license UI (#11182)

This commit is contained in:
Alex 2024-07-18 10:56:27 -05:00 committed by GitHub
parent 88f62087fd
commit ef0e1a81b9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 1157 additions and 148 deletions

View File

@ -29,3 +29,4 @@ web/node_modules/
web/coverage/ web/coverage/
web/.svelte-kit web/.svelte-kit
web/build/ web/build/
web/.env

View File

@ -1,7 +1,7 @@
--- ---
title: The Immich core team goes full-time title: The Immich core team goes full-time
authors: [alextran] authors: [alextran]
tags: [update, announcement, futo] tags: [update, announcement, FUTO]
date: 2024-05-01T00:00 date: 2024-05-01T00:00
--- ---

View File

@ -0,0 +1,91 @@
---
title: Licensing announcement - Purchase a license to support Immich
authors: [alextran]
tags: [update, announcement, FUTO]
date: 2024-07-18T00:00
---
Hello everybody,
Firstly, on behalf of the Immich team, I'd like to thank everybody for your continuous support of Immich since the very first day! Your contributions, encouragement, and community engagement have helped bring Immich to its current state. The team and I are forever grateful for that.
Since our [last announcement of the core team joining FUTO to work on Immich full-time](https://immich.app/blog/2024/immich-core-team-goes-fulltime), one of the goals of our new position is to foster a healthy relationship between the developers and the users. We believe that this enables us to create great software, establish transparent policies and build trust.
We want to build a great software application that brings value to you and your loved ones' lives. We are not using you as a product, i.e., selling or tracking your data. We are not putting annoying ads into our software. We respect your privacy. We want to be compensated for the hard work we put in to build Immich for you.
With those notes, we have enabled a way for you to financially support the continued development of Immich, ensuring the software can move forward and will be maintained, by offering a lifetime license of the software. We think if you like and use software, you should pay for it, but _we're never going to force anyone to pay or try to limit Immich for those who don't._
There are two types of license that you can choose to purchase: **Server License** and **Individual License**.
### Server License
This is a lifetime license costing **$99.99**. The license is applied to the whole server. You and all users that use your server are licensed.
### Individual License
This is a lifetime license costing **$24.99**. The license is applied to a single user, and can be used on any server they choose to connect to.
<img
width="837"
alt="license-social-gh"
src="https://github.com/user-attachments/assets/241932ed-ef3b-44ec-a9e2-ee80754e0cca"
/>
You can purchase the license on [our page - https://buy.immich.app](https://buy.immich.app).
Starting with release `v1.109.0` you can purchase and enter your purchased license key directly in the app.
<img
width="1414"
alt="license-page-gh"
src="https://github.com/user-attachments/assets/364fc32a-f6ef-4594-9fea-28d5a26ad77c"
/>
## Thank you
Thank you again for your support, this will help create a strong foundation and stability for the Immich team to continue developing and maintaining the project that you love to use.
<p align="center">
<img
src="https://media.giphy.com/media/v1.Y2lkPTc5MGI3NjExbjY2eWc5Y2F0ZW56MmR4aWE0dDhzZXlidXRmYWZyajl1bWZidXZpcyZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/87CKDqErVfMqY/giphy.gif"
width="550"
title="SUPPORT THE PROJECT!"
/>
</p>
<br />
<br />
Cheers! 🎉
Immich team
# FAQ
### 1. Where can I purchase a license?
There are several places where you can purchase the license from
- [https://buy.immich.app](https://buy.immich.app)
- [https://pay.futo.org](https://pay.futo.org/)
- or directly from the app.
### 2. Do I need both _Individual License_ and _Server License_?
No,
If you are the admin and the sole user, or your instance has less than a total of 4 users, you can buy the **Individual License** for each user.
If your instance has more than 4 users, it is more cost-effective to buy the **Server License**, which will license all the users on your instance.
### 3. What do I do if I don't pay?
You can continue using Immich for an unlimited trial period.
### 4. Will there be any paywalled features?
No, there will never be any paywalled features.
### 5. Where can I get support regarding payment issues?
You can email us with your `orderId` and your email address `billing@futo.org` or on our Discord server.

6
e2e/package-lock.json generated
View File

@ -56,12 +56,12 @@
"devDependencies": { "devDependencies": {
"@immich/sdk": "file:../open-api/typescript-sdk", "@immich/sdk": "file:../open-api/typescript-sdk",
"@types/byte-size": "^8.1.0", "@types/byte-size": "^8.1.0",
"@types/cli-progress": "^3.11.6", "@types/cli-progress": "^3.11.0",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/node": "^20.14.10", "@types/node": "^20.14.10",
"@typescript-eslint/eslint-plugin": "^7.16.0", "@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.16.0", "@typescript-eslint/parser": "^7.0.0",
"@vitest/coverage-v8": "^1.2.2", "@vitest/coverage-v8": "^1.2.2",
"byte-size": "^8.1.1", "byte-size": "^8.1.1",
"cli-progress": "^3.12.0", "cli-progress": "^3.12.0",

View File

@ -507,7 +507,7 @@ describe('/asset', () => {
expect(status).toEqual(200); expect(status).toEqual(200);
}); });
it('should geocode country from gps data in the middle of nowhere', async () => { it.skip('should geocode country from gps data in the middle of nowhere', async () => {
const { status } = await request(app) const { status } = await request(app)
.put(`/assets/${user1Assets[0].id}`) .put(`/assets/${user1Assets[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`) .set('Authorization', `Bearer ${user1.accessToken}`)

View File

@ -95,7 +95,7 @@ export class ServerController {
@Get('license') @Get('license')
@Authenticated({ admin: true }) @Authenticated({ admin: true })
getServerLicense(): Promise<LicenseKeyDto | null> { getServerLicense(): Promise<LicenseResponseDto | null> {
return this.service.getLicense(); return this.service.getLicense();
} }
} }

View File

@ -0,0 +1,186 @@
import {
Body,
Button,
Column,
Container,
Head,
Hr,
Html,
Img,
Link,
Preview,
Row,
Section,
Text,
} from '@react-email/components';
import * as CSS from 'csstype';
import * as React from 'react';
/**
* Template to be used for FUTOPay project
* Variable is {{LICENSEKEY}}
* */
export const LicenseEmail = () => (
<Html>
<Head />
<Preview>Your Immich Server License</Preview>
<Body
style={{
margin: 0,
padding: 0,
backgroundColor: '#f4f4f4',
color: 'rgb(28,28,28)',
fontFamily: 'Overpass, sans-serif',
fontSize: '18px',
lineHeight: '24px',
}}
>
<Container
style={{
width: '540px',
maxWidth: '100%',
padding: '10px',
margin: '0 auto',
}}
>
<Section
style={{
padding: '36px',
tableLayout: 'fixed',
backgroundColor: '#fefefe',
borderRadius: '16px',
textAlign: 'center' as const,
}}
>
<Img
src="https://immich.app/img/immich-logo-inline-light.png"
alt="Immich"
style={{
height: 'auto',
margin: '0 auto 48px auto',
width: '50%',
alignSelf: 'center',
color: 'white',
}}
/>
<Text style={text}>Thank you for supporting Immich and open-source software</Text>
<Text style={text}>
Your <strong>Immich</strong> license key is
</Text>
<Section
style={{
textAlign: 'center',
background: 'rgb(225, 225, 225)',
borderRadius: '16px',
marginBottom: '25px',
}}
>
<Text style={{ fontFamily: 'monospace', fontWeight: 600, color: 'rgb(66, 80, 175)' }}>
{'{{LICENSEKEY}}'}
</Text>
</Section>
{/* <Text style={text}>
To activate your instance, you can click the following button or copy and paste the link below to your
browser
</Text>
<Row>
<Column align="center">
<Button
style={button}
href={`https://my.immich.app/link?target=activate_license&licenseKey={{LICENSEKEY}}&activationKey={{ACTIVATIONKEY}}`}
>
Activate
</Button>
</Column>
</Row>
<Row>
<Column align="center">
<a
style={{ marginTop: '50px', color: 'rgb(66, 80, 175)', fontSize: '0.9rem' }}
href={`https://my.immich.app/link?target=activate_license&licenseKey={{LICENSEKEY}}&activationKey={{ACTIVATIONKEY}}`}
>
https://my.immich.app/link?target=activate_license&licenseKey={'{{LICENSEKEY}}'}&activationKey=
{'{{ACTIVATIONKEY}}'}
</a>
</Column>
</Row> */}
</Section>
<Section style={{ textAlign: 'center' }}>
<Row>
<Column align="center">
<Link href="https://futo.org">
<Img
src="https://futo.org/images/FutoMainLogo.svg"
alt="FUTO"
style={{
height: '24px',
marginTop: '25px',
marginBottom: '25px',
}}
/>
</Link>
</Column>
</Row>
</Section>
<Hr style={{ color: 'rgb(66, 80, 175)', marginTop: '0' }} />
<Section style={{ textAlign: 'center' }}>
<Column align="center">
<Link href="https://apps.apple.com/sg/app/immich/id1613945652">
<Img
src={`https://immich.app/img/ios-app-store-badge.png`}
alt="Immich"
style={{ height: '72px', padding: '14px' }}
/>
</Link>
<Link href="https://play.google.com/store/apps/details?id=app.alextran.immich">
<Img src={`https://immich.app/img/google-play-badge.png`} height="96px" alt="Immich" />
</Link>
</Column>
</Section>
<Text
style={{
color: '#6a737d',
fontSize: '0.8rem',
textAlign: 'center' as const,
marginTop: '14px',
}}
>
<Link href="https://immich.app">Immich</Link> project is available under GNU AGPL v3 license.
</Text>
</Container>
</Body>
</Html>
);
LicenseEmail.PreviewProps = {};
export default LicenseEmail;
const text = {
margin: '0 0 24px 0',
textAlign: 'left' as const,
fontSize: '16px',
lineHeight: '24px',
};
const button: CSS.Properties = {
backgroundColor: 'rgb(66, 80, 175)',
margin: '1em 0',
padding: '0.75em 3em',
color: '#fff',
fontSize: '1em',
fontWeight: 600,
lineHeight: 1.5,
textTransform: 'uppercase',
borderRadius: '9999px',
};

View File

@ -164,7 +164,7 @@ export class ServerService implements OnEvents {
await this.systemMetadataRepository.delete(SystemMetadataKey.LICENSE); await this.systemMetadataRepository.delete(SystemMetadataKey.LICENSE);
} }
async getLicense(): Promise<LicenseKeyDto | null> { async getLicense(): Promise<LicenseResponseDto | null> {
return this.systemMetadataRepository.get(SystemMetadataKey.LICENSE); return this.systemMetadataRepository.get(SystemMetadataKey.LICENSE);
} }

13
web/package-lock.json generated
View File

@ -47,6 +47,7 @@
"@typescript-eslint/parser": "^7.1.0", "@typescript-eslint/parser": "^7.1.0",
"@vitest/coverage-v8": "^1.3.1", "@vitest/coverage-v8": "^1.3.1",
"autoprefixer": "^10.4.17", "autoprefixer": "^10.4.17",
"dotenv": "^16.4.5",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.35.1", "eslint-plugin-svelte": "^2.35.1",
@ -3740,6 +3741,18 @@
"resolved": "https://registry.npmjs.org/dom-to-image/-/dom-to-image-2.6.0.tgz", "resolved": "https://registry.npmjs.org/dom-to-image/-/dom-to-image-2.6.0.tgz",
"integrity": "sha512-Dt0QdaHmLpjURjU7Tnu3AgYSF2LuOmksSGsUcE6ItvJoCWTBEmiMXcqBdNSAm9+QbbwD7JMoVsuuKX6ZVQv1qA==" "integrity": "sha512-Dt0QdaHmLpjURjU7Tnu3AgYSF2LuOmksSGsUcE6ItvJoCWTBEmiMXcqBdNSAm9+QbbwD7JMoVsuuKX6ZVQv1qA=="
}, },
"node_modules/dotenv": {
"version": "16.4.5",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz",
"integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==",
"dev": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/earcut": { "node_modules/earcut": {
"version": "2.2.4", "version": "2.2.4",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz",

View File

@ -40,6 +40,7 @@
"@typescript-eslint/parser": "^7.1.0", "@typescript-eslint/parser": "^7.1.0",
"@vitest/coverage-v8": "^1.3.1", "@vitest/coverage-v8": "^1.3.1",
"autoprefixer": "^10.4.17", "autoprefixer": "^10.4.17",
"dotenv": "^16.4.5",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.35.1", "eslint-plugin-svelte": "^2.35.1",

5
web/src/app.d.ts vendored
View File

@ -27,3 +27,8 @@ declare namespace svelteHTML {
'on:zoomImage'?: () => void; 'on:zoomImage'?: () => void;
} }
} }
declare module '$env/static/public' {
export const PUBLIC_IMMICH_PAY_HOST: string;
export const PUBLIC_IMMICH_BUY_HOST: string;
}

View File

@ -39,7 +39,7 @@
} else if (width === 'narrow') { } else if (width === 'narrow') {
modalWidth = 'w-[28rem]'; modalWidth = 'w-[28rem]';
} else { } else {
modalWidth = 'sm:max-w-lg'; modalWidth = 'sm:max-w-4xl';
} }
} }
</script> </script>

View File

@ -0,0 +1,18 @@
<script lang="ts">
import Button from '$lib/components/elements/buttons/button.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import { t } from 'svelte-i18n';
import { mdiPartyPopper } from '@mdi/js';
export let onDone: () => void;
</script>
<div class="m-auto w-3/4 text-center flex flex-col place-content-center place-items-center mb-6 dark:text-white">
<Icon path={mdiPartyPopper} class="text-immich-primary dark:text-immich-dark-primary" size="96" />
<p class="text-4xl mt-8 font-bold">{$t('license_activated_title')}</p>
<p class="text-lg mt-6">{$t('license_activated_subtitle')}</p>
<div class="mt-10 w-full">
<Button fullwidth on:click={onDone}>OK</Button>
</div>
</div>

View File

@ -0,0 +1,70 @@
<script lang="ts">
import { user } from '$lib/stores/user.store';
import { handleError } from '$lib/utils/handle-error';
import ServerLicenseCard from './server-license-card.svelte';
import UserLicenseCard from './user-license-card.svelte';
import { activateLicense, getActivationKey } from '$lib/utils/license-utils';
import Button from '$lib/components/elements/buttons/button.svelte';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { licenseStore } from '$lib/stores/license.store';
import { t } from 'svelte-i18n';
export let onActivate: () => void;
let licenseKey = '';
let isLoading = false;
const activate = async () => {
try {
licenseKey = licenseKey.trim();
isLoading = true;
const activationKey = await getActivationKey(licenseKey);
await activateLicense(licenseKey, activationKey);
onActivate();
licenseStore.setLicenseStatus(true);
} catch (error) {
handleError(error, $t('license_failed_activation'));
} finally {
isLoading = false;
}
};
</script>
<section class="p-4">
<div>
<h1 class="text-4xl font-bold text-immich-primary dark:text-immich-dark-primary tracking-wider">
{$t('license_license_title')}
</h1>
<p class="text-lg mt-2 dark:text-immich-gray">{$t('license_license_subtitle')}</p>
</div>
<div class="flex gap-6 mt-4 justify-between">
{#if $user.isAdmin}
<ServerLicenseCard />
{/if}
<UserLicenseCard />
</div>
<div class="mt-6">
<p class="dark:text-immich-gray">{$t('license_input_suggestion')}</p>
<form class="mt-2 flex gap-2" on:submit={activate}>
<input
class="immich-form-input w-full"
id="licensekey"
type="text"
bind:value={licenseKey}
required
placeholder="IMCL-0KEY-0CAN-00BE-FOUD-FROM-YOUR-EMAIL-INBX"
disabled={isLoading}
/>
<Button type="submit" rounded="lg"
>{#if isLoading}
<LoadingSpinner />
{:else}
{$t('license_button_activate')}
{/if}</Button
>
</form>
</div>
</section>

View File

@ -0,0 +1,25 @@
<script lang="ts">
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import LicenseActivationSuccess from '$lib/components/shared-components/license/license-activation-success.svelte';
import LicenseContent from '$lib/components/shared-components/license/license-content.svelte';
import Portal from '$lib/components/shared-components/portal/portal.svelte';
export let onClose: () => void;
let showLicenseActivated = false;
</script>
<Portal>
<FullScreenModal showLogo title={''} {onClose} width="wide">
{#if showLicenseActivated}
<LicenseActivationSuccess onDone={onClose} />
{:else}
<LicenseContent
onActivate={() => {
showLicenseActivated = true;
}}
/>
{/if}
</FullScreenModal>
</Portal>

View File

@ -0,0 +1,44 @@
<script lang="ts">
import Button from '$lib/components/elements/buttons/button.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import { ImmichLicense } from '$lib/constants';
import { getLicenseLink } from '$lib/utils/license-utils';
import { mdiCheckCircleOutline, mdiServer } from '@mdi/js';
import { t } from 'svelte-i18n';
</script>
<!-- SERVER LICENSE -->
<div class="border border-gray-300 dark:border-gray-800 w-[375px] p-8 rounded-3xl bg-gray-100 dark:bg-gray-900">
<div class="text-immich-primary dark:text-immich-dark-primary">
<Icon path={mdiServer} size="56" />
<p class="font-semibold text-lg mt-1">{$t('license_server_title')}</p>
</div>
<div class="mt-4 dark:text-immich-gray">
<p class="text-6xl font-bold">$99<span class="text-2xl font-medium">.99</span></p>
<p>{$t('license_per_server')}</p>
</div>
<div class="flex flex-col justify-between h-[200px] dark:text-immich-gray">
<div class="mt-6 flex flex-col gap-1">
<div class="grid grid-cols-[36px_auto]">
<Icon path={mdiCheckCircleOutline} size="24" class="text-green-500 self-center" />
<p class="self-center">{$t('license_server_description_1')}</p>
</div>
<div class="grid grid-cols-[36px_auto]">
<Icon path={mdiCheckCircleOutline} size="24" class="text-green-500 self-center" />
<p class="self-center">{$t('license_lifetime_description')}</p>
</div>
<div class="grid grid-cols-[36px_auto]">
<Icon path={mdiCheckCircleOutline} size="24" class="text-green-500 self-center" />
<p class="self-center">{$t('license_server_description_2')}</p>
</div>
</div>
<a href={getLicenseLink(ImmichLicense.Server)}>
<Button fullwidth>{$t('license_button_select')}</Button>
</a>
</div>
</div>

View File

@ -0,0 +1,39 @@
<script lang="ts">
import Button from '$lib/components/elements/buttons/button.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import { ImmichLicense } from '$lib/constants';
import { getLicenseLink } from '$lib/utils/license-utils';
import { mdiAccount, mdiCheckCircleOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
</script>
<!-- USER LICENSE -->
<div class="border border-gray-300 dark:border-gray-800 w-[375px] p-8 rounded-3xl bg-gray-100 dark:bg-gray-900">
<div class="text-immich-primary dark:text-immich-dark-primary">
<Icon path={mdiAccount} size="56" />
<p class="font-semibold text-lg mt-1">{$t('license_individual_title')}</p>
</div>
<div class="mt-4 dark:text-immich-gray">
<p class="text-6xl font-bold">$24<span class="text-2xl font-medium">.99</span></p>
<p>{$t('license_per_user')}</p>
</div>
<div class="flex flex-col justify-between h-[200px] dark:text-immich-gray">
<div class="mt-6 flex flex-col gap-1">
<div class="grid grid-cols-[36px_auto]">
<Icon path={mdiCheckCircleOutline} size="24" class="text-green-500 self-center" />
<p class="self-center">{$t('license_individual_description_1')}</p>
</div>
<div class="grid grid-cols-[36px_auto]">
<Icon path={mdiCheckCircleOutline} size="24" class="text-green-500 self-center" />
<p class="self-center">{$t('license_lifetime_description')}</p>
</div>
</div>
<a href={getLicenseLink(ImmichLicense.Client)}>
<Button fullwidth>{$t('license_button_select')}</Button>
</a>
</div>
</div>

View File

@ -31,6 +31,7 @@
const logOut = async () => { const logOut = async () => {
const { redirectUri } = await logout(); const { redirectUri } = await logout();
if (redirectUri.startsWith('/')) { if (redirectUri.startsWith('/')) {
await goto(redirectUri); await goto(redirectUri);
} else { } else {

View File

@ -45,6 +45,24 @@
} }
</script> </script>
<!--
@component
Allow rendering a component in a different part of the DOM.
### Props
- `target` - HTMLElement i.e "body", "html", default is "body"
### Default Slot
Used for every occurrence of an HTML tag in a message
- `tag` - Name of the tag
@example
```html
<Portal target="body">
<p>Your component in here</p>
</Portal>
```
-->
<script lang="ts"> <script lang="ts">
/** /**
* DOM Element or CSS Selector * DOM Element or CSS Selector

View File

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import BottomInfo from '$lib/components/shared-components/side-bar/bottom-info.svelte';
import SideBarLink from '$lib/components/shared-components/side-bar/side-bar-link.svelte'; import SideBarLink from '$lib/components/shared-components/side-bar/side-bar-link.svelte';
import SideBarSection from '$lib/components/shared-components/side-bar/side-bar-section.svelte'; import SideBarSection from '$lib/components/shared-components/side-bar/side-bar-section.svelte';
import StatusBox from '$lib/components/shared-components/status-box.svelte';
import { AppRoute } from '$lib/constants'; import { AppRoute } from '$lib/constants';
import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiSync, mdiTools } from '@mdi/js'; import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiSync, mdiTools } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@ -17,7 +17,5 @@
<SideBarLink title={$t('repair')} routeId={AppRoute.ADMIN_REPAIR} icon={mdiTools} preloadData={false} /> <SideBarLink title={$t('repair')} routeId={AppRoute.ADMIN_REPAIR} icon={mdiTools} preloadData={false} />
</nav> </nav>
<div class="mb-6 mt-auto"> <BottomInfo />
<StatusBox />
</div>
</SideBarSection> </SideBarSection>

View File

@ -0,0 +1,17 @@
<script lang="ts">
import LicenseInfo from './license-info.svelte';
import ServerStatus from './server-status.svelte';
import StorageSpace from './storage-space.svelte';
</script>
<div class="mt-auto">
<StorageSpace />
</div>
<div class="mb-2">
<LicenseInfo />
</div>
<div class="mb-6">
<ServerStatus />
</div>

View File

@ -0,0 +1,92 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import { mdiClose, mdiInformationOutline, mdiLicense } from '@mdi/js';
import Portal from '$lib/components/shared-components/portal/portal.svelte';
import Button from '$lib/components/elements/buttons/button.svelte';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import LicenseModal from '$lib/components/shared-components/license/license-modal.svelte';
import { licenseStore } from '$lib/stores/license.store';
import { t } from 'svelte-i18n';
import { goto } from '$app/navigation';
import { AppRoute } from '$lib/constants';
import { getAccountAge } from '$lib/utils/auth';
let showMessage = false;
let isOpen = false;
const { isLicenseActivated } = licenseStore;
const openLicenseModal = () => {
isOpen = true;
showMessage = false;
};
</script>
{#if isOpen}
<LicenseModal onClose={() => (isOpen = false)} />
{/if}
<div class="hidden md:block license-status pl-4 text-sm">
{#if $isLicenseActivated}
<button
on:click={() => goto(`${AppRoute.USER_SETTINGS}?isOpen=user-license-settings`)}
class="w-full"
type="button"
>
<div class="flex gap-1 mt-2 place-items-center dark:bg-immich-dark-primary/10 bg-gray-100 py-3 px-2 rounded-lg">
<Icon path={mdiLicense} size="18" class="text-immich-primary dark:text-immich-dark-primary" />
<p class="dark:text-gray-100">{$t('license_info_licensed')}</p>
</div>
</button>
{:else}
<button
type="button"
on:click={openLicenseModal}
on:mouseenter={() => (showMessage = true)}
class="py-3 px-2 flex justify-between place-items-center place-content-center border border-gray-300 dark:border-immich-dark-primary/50 mt-2 rounded-lg shadow-sm dark:bg-immich-dark-primary/10 w-full"
>
<div class="flex place-items-center place-content-center gap-1">
<Icon path={mdiLicense} size="18" class="text-immich-dark-gray/75 dark:text-immich-gray/85" />
<p class="text-immich-dark-gray/75 dark:text-immich-gray">{$t('license_info_unlicensed')}</p>
</div>
<div class="text-immich-primary dark:text-immich-dark-primary flex place-items-center gap-[2px] font-medium">
{$t('license_button_buy')}
<span role="contentinfo">
<Icon path={mdiInformationOutline}></Icon>
</span>
</div>
</button>
{/if}
</div>
<Portal target="body">
{#if showMessage && getAccountAge() > 14}
<div
class="w-[265px] absolute bottom-[75px] left-[255px] bg-white dark:bg-gray-800 dark:text-white text-black rounded-xl z-10 shadow-2xl px-4 py-5"
>
<div class="flex justify-between place-items-center">
<Icon path={mdiLicense} size="44" class="text-immich-dark-gray/75 dark:text-immich-gray" />
<CircleIconButton
icon={mdiClose}
on:click={() => {
showMessage = false;
}}
title="Close"
size="18"
class="text-immich-dark-gray/85 dark:text-immich-gray"
/>
</div>
<h1 class="text-lg font-medium my-3">{$t('license_trial_info_1')}</h1>
<p class="text-immich-dark-gray/80 dark:text-immich-gray text-balance">
{$t('license_trial_info_2')}
<span class="text-immich-primary dark:text-immich-dark-primary font-semibold">
{$t('license_trial_info_3', { values: { accountAge: getAccountAge() } })}</span
>. {$t('license_trial_info_4')}
</p>
<div class="mt-3">
<Button size="sm" fullwidth on:click={openLicenseModal}>{$t('license_button_buy_license')}</Button>
</div>
</div>
{/if}
</Portal>

View File

@ -0,0 +1,49 @@
<script lang="ts">
import ServerAboutModal from '$lib/components/shared-components/server-about-modal.svelte';
import { websocketStore } from '$lib/stores/websocket';
import { requestServerInfo } from '$lib/utils/auth';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { getAboutInfo, type ServerAboutResponseDto } from '@immich/sdk';
const { serverVersion, connected } = websocketStore;
let isOpen = false;
$: version = $serverVersion ? `v${$serverVersion.major}.${$serverVersion.minor}.${$serverVersion.patch}` : null;
let aboutInfo: ServerAboutResponseDto;
onMount(async () => {
await requestServerInfo();
aboutInfo = await getAboutInfo();
});
</script>
{#if isOpen}
<ServerAboutModal onClose={() => (isOpen = false)} info={aboutInfo} />
{/if}
<div
class="text-sm hidden group-hover:sm:flex md:flex pl-5 pr-1 place-items-center place-content-center justify-between"
>
{#if $connected}
<div class="flex gap-2 place-items-center place-content-center">
<div class="w-[7px] h-[7px] bg-green-500 rounded-full" />
<p class="dark:text-immich-gray">{$t('server_online')}</p>
</div>
{:else}
<div class="flex gap-2 place-items-center place-content-center">
<div class="w-[7px] h-[7px] bg-red-500 rounded-full" />
<p class="text-red-500">{$t('server_offline')}</p>
</div>
{/if}
<div class="flex justify-between justify-items-center">
{#if $connected && version}
<button type="button" on:click={() => (isOpen = true)} class="dark:text-immich-gray">{version}</button>
{:else}
<p class="text-red-500">{$t('unknown')}</p>
{/if}
</div>
</div>

View File

@ -21,12 +21,12 @@
mdiToolbox, mdiToolbox,
mdiToolboxOutline, mdiToolboxOutline,
} from '@mdi/js'; } from '@mdi/js';
import StatusBox from '../status-box.svelte';
import SideBarSection from './side-bar-section.svelte'; import SideBarSection from './side-bar-section.svelte';
import SideBarLink from './side-bar-link.svelte'; import SideBarLink from './side-bar-link.svelte';
import MoreInformationAssets from '$lib/components/shared-components/side-bar/more-information-assets.svelte'; import MoreInformationAssets from '$lib/components/shared-components/side-bar/more-information-assets.svelte';
import MoreInformationAlbums from '$lib/components/shared-components/side-bar/more-information-albums.svelte'; import MoreInformationAlbums from '$lib/components/shared-components/side-bar/more-information-albums.svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import BottomInfo from '$lib/components/shared-components/side-bar/bottom-info.svelte';
let isArchiveSelected: boolean; let isArchiveSelected: boolean;
let isFavoritesSelected: boolean; let isFavoritesSelected: boolean;
@ -136,8 +136,5 @@
{/if} {/if}
</nav> </nav>
<!-- Status Box --> <BottomInfo />
<div class="mb-6 mt-auto">
<StatusBox />
</div>
</SideBarSection> </SideBarSection>

View File

@ -0,0 +1,82 @@
<script lang="ts">
import ServerAboutModal from '$lib/components/shared-components/server-about-modal.svelte';
import { locale } from '$lib/stores/preferences.store';
import { serverInfo } from '$lib/stores/server-info.store';
import { user } from '$lib/stores/user.store';
import { requestServerInfo } from '$lib/utils/auth';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { getByteUnitString } from '../../../utils/byte-units';
import LoadingSpinner from '../loading-spinner.svelte';
import { getAboutInfo, type ServerAboutResponseDto } from '@immich/sdk';
let usageClasses = '';
let isOpen = false;
$: hasQuota = $user?.quotaSizeInBytes !== null;
$: availableBytes = (hasQuota ? $user?.quotaSizeInBytes : $serverInfo?.diskSizeRaw) || 0;
$: usedBytes = (hasQuota ? $user?.quotaUsageInBytes : $serverInfo?.diskUseRaw) || 0;
$: usedPercentage = Math.round((usedBytes / availableBytes) * 100);
let aboutInfo: ServerAboutResponseDto;
const onUpdate = () => {
usageClasses = getUsageClass();
};
const getUsageClass = () => {
if (usedPercentage >= 95) {
return 'bg-red-500';
}
if (usedPercentage > 80) {
return 'bg-yellow-500';
}
return 'bg-immich-primary dark:bg-immich-dark-primary';
};
$: $user && onUpdate();
onMount(async () => {
await requestServerInfo();
aboutInfo = await getAboutInfo();
});
</script>
{#if isOpen}
<ServerAboutModal onClose={() => (isOpen = false)} info={aboutInfo} />
{/if}
<div
class="hidden md:block storage-status p-4 bg-gray-100 dark:bg-immich-dark-primary/10 ml-4 rounded-lg text-sm"
title={$t('storage_usage', {
values: {
used: getByteUnitString(usedBytes, $locale, 3),
available: getByteUnitString(availableBytes, $locale, 3),
},
})}
>
<div class="hidden group-hover:sm:block md:block">
<p class="font-medium text-immich-dark-gray dark:text-white mb-2">{$t('storage')}</p>
{#if $serverInfo}
<p class="text-gray-500 dark:text-gray-300">
{$t('storage_usage', {
values: {
used: getByteUnitString(usedBytes, $locale),
available: getByteUnitString(availableBytes, $locale),
},
})}
</p>
<div class="mt-4 h-[7px] w-full rounded-full bg-gray-200 dark:bg-gray-700">
<div class="h-[7px] rounded-full {usageClasses}" style="width: {usedPercentage}%" />
</div>
{:else}
<div class="mt-2">
<LoadingSpinner />
</div>
{/if}
</div>
</div>

View File

@ -1,125 +0,0 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import ServerAboutModal from '$lib/components/shared-components/server-about-modal.svelte';
import { locale } from '$lib/stores/preferences.store';
import { serverInfo } from '$lib/stores/server-info.store';
import { user } from '$lib/stores/user.store';
import { websocketStore } from '$lib/stores/websocket';
import { requestServerInfo } from '$lib/utils/auth';
import { mdiChartPie, mdiDns } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { getByteUnitString } from '../../utils/byte-units';
import LoadingSpinner from './loading-spinner.svelte';
import { getAboutInfo, type ServerAboutResponseDto } from '@immich/sdk';
const { serverVersion, connected } = websocketStore;
let usageClasses = '';
let isOpen = false;
$: version = $serverVersion ? `v${$serverVersion.major}.${$serverVersion.minor}.${$serverVersion.patch}` : null;
$: hasQuota = $user?.quotaSizeInBytes !== null;
$: availableBytes = (hasQuota ? $user?.quotaSizeInBytes : $serverInfo?.diskSizeRaw) || 0;
$: usedBytes = (hasQuota ? $user?.quotaUsageInBytes : $serverInfo?.diskUseRaw) || 0;
$: usedPercentage = Math.round((usedBytes / availableBytes) * 100);
let aboutInfo: ServerAboutResponseDto;
const onUpdate = () => {
usageClasses = getUsageClass();
};
const getUsageClass = () => {
if (usedPercentage >= 95) {
return 'bg-red-500';
}
if (usedPercentage > 80) {
return 'bg-yellow-500';
}
return 'bg-immich-primary dark:bg-immich-dark-primary';
};
$: $user && onUpdate();
onMount(async () => {
await requestServerInfo();
aboutInfo = await getAboutInfo();
});
</script>
{#if isOpen}
<ServerAboutModal onClose={() => (isOpen = false)} info={aboutInfo} />
{/if}
<div class="dark:text-immich-dark-fg">
<div
class="storage-status grid grid-cols-[64px_auto]"
title={$t('storage_usage', {
values: {
used: getByteUnitString(usedBytes, $locale, 3),
available: getByteUnitString(availableBytes, $locale, 3),
},
})}
>
<div class="pb-[2.15rem] pl-5 pr-6 text-immich-primary dark:text-immich-dark-primary group-hover:sm:pb-0 md:pb-0">
<Icon path={mdiChartPie} size="24" />
</div>
<div class="hidden group-hover:sm:block md:block">
<p class="text-sm font-medium text-immich-primary dark:text-immich-dark-primary">{$t('storage')}</p>
{#if $serverInfo}
<div class="my-2 h-[7px] w-full rounded-full bg-gray-200 dark:bg-gray-700">
<div class="h-[7px] rounded-full {usageClasses}" style="width: {usedPercentage}%" />
</div>
<p class="text-xs">
{$t('storage_usage', {
values: {
used: getByteUnitString(usedBytes, $locale),
available: getByteUnitString(availableBytes, $locale),
},
})}
</p>
{:else}
<div class="mt-2">
<LoadingSpinner />
</div>
{/if}
</div>
</div>
<div>
<hr class="my-4 ml-5 dark:border-immich-dark-gray" />
</div>
<div class="server-status grid grid-cols-[64px_auto]">
<div class="pb-11 pl-5 pr-6 text-immich-primary dark:text-immich-dark-primary group-hover:sm:pb-0 md:pb-0">
<Icon path={mdiDns} size="26" />
</div>
<div class="hidden text-xs group-hover:sm:block md:block">
<p class="text-sm font-medium text-immich-primary dark:text-immich-dark-primary">{$t('server')}</p>
<div class="mt-2 flex justify-between justify-items-center">
<p>{$t('status')}</p>
{#if $connected}
<p class="font-medium text-immich-primary dark:text-immich-dark-primary">{$t('online')}</p>
{:else}
<p class="font-medium text-red-500">{$t('offline')}</p>
{/if}
</div>
<div class="mt-2 flex justify-between justify-items-center">
<p>{$t('version')}</p>
{#if $connected && version}
<button
type="button"
on:click={() => (isOpen = true)}
class="font-medium text-immich-primary dark:text-immich-dark-primary">{version}</button
>
{:else}
<p class="font-medium text-red-500">{$t('unknown')}</p>
{/if}
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,158 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import { onMount } from 'svelte';
import { licenseStore } from '$lib/stores/license.store';
import { user } from '$lib/stores/user.store';
import {
deleteServerLicense,
deleteUserLicense,
getAboutInfo,
getMyUser,
getServerLicense,
type LicenseResponseDto,
} from '@immich/sdk';
import Icon from '$lib/components/elements/icon.svelte';
import { mdiLicense } from '@mdi/js';
import Button from '$lib/components/elements/buttons/button.svelte';
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
import { handleError } from '$lib/utils/handle-error';
import LicenseContent from '$lib/components/shared-components/license/license-content.svelte';
import { t } from 'svelte-i18n';
import { getAccountAge } from '$lib/utils/auth';
const { isLicenseActivated } = licenseStore;
let isServerLicense = false;
let serverLicenseInfo: LicenseResponseDto | null = null;
const accountAge = getAccountAge();
const checkLicenseInfo = async () => {
const serverInfo = await getAboutInfo();
isServerLicense = serverInfo.licensed;
const userInfo = await getMyUser();
if (userInfo.license) {
$user = { ...$user, license: userInfo.license };
}
if (isServerLicense && $user.isAdmin) {
serverLicenseInfo = (await getServerLicense()) as LicenseResponseDto | null;
}
};
onMount(async () => {
if (!$isLicenseActivated) {
return;
}
await checkLicenseInfo();
});
const removeUserLicense = async () => {
try {
const isConfirmed = await dialogController.show({
title: 'Remove License',
prompt: 'Are you sure you want to remove the license?',
confirmText: 'Remove',
cancelText: 'Cancel',
});
if (!isConfirmed) {
return;
}
await deleteUserLicense();
licenseStore.setLicenseStatus(false);
} catch (error) {
handleError(error, 'Failed to remove license');
}
};
const removeServerLicense = async () => {
try {
const isConfirmed = await dialogController.show({
title: 'Remove License',
prompt: 'Are you sure you want to remove the Server license?',
confirmText: 'Remove',
cancelText: 'Cancel',
});
if (!isConfirmed) {
return;
}
await deleteServerLicense();
licenseStore.setLicenseStatus(false);
} catch (error) {
handleError(error, 'Failed to remove license');
}
};
const onLicenseActivated = async () => {
licenseStore.setLicenseStatus(true);
await checkLicenseInfo();
};
</script>
<section class="my-4">
<div in:fade={{ duration: 500 }}>
{#if $isLicenseActivated}
{#if isServerLicense}
<div
class="bg-gray-50 border border-immich-dark-primary/50 dark:bg-immich-dark-primary/15 p-6 pr-12 rounded-xl flex place-content-center gap-4"
>
<Icon path={mdiLicense} size="56" class="text-immich-primary dark:text-immich-dark-primary" />
<div>
<p class="text-immich-primary dark:text-immich-dark-primary font-semibold text-lg">Server License</p>
{#if $user.isAdmin && serverLicenseInfo?.activatedAt}
<p class="dark:text-white text-sm mt-1 col-start-2">
Activated on {new Date(serverLicenseInfo?.activatedAt).toLocaleDateString()}
</p>
{:else}
<p class="dark:text-white">Your license is managed by the admin</p>
{/if}
</div>
</div>
<div class="text-right mt-4">
<Button size="sm" color="red" on:click={removeServerLicense}>Remove license</Button>
</div>
{:else}
<div
class="bg-gray-50 border border-immich-dark-primary/50 dark:bg-immich-dark-primary/15 p-6 pr-12 rounded-xl flex place-content-center gap-4"
>
<Icon path={mdiLicense} size="56" class="text-immich-primary dark:text-immich-dark-primary" />
<div>
<p class="text-immich-primary dark:text-immich-dark-primary font-semibold text-lg">Individual License</p>
{#if $user.license?.activatedAt}
<p class="dark:text-white text-sm mt-1 col-start-2">
Activated on {new Date($user.license?.activatedAt).toLocaleDateString()}
</p>
{/if}
</div>
</div>
<div class="text-right mt-4">
<Button size="sm" color="red" on:click={removeUserLicense}>Remove license</Button>
</div>
{/if}
{:else}
{#if accountAge > 14}
<div
class="text-center bg-gray-100 border border-immich-dark-primary/50 dark:bg-immich-dark-primary/15 p-4 rounded-xl"
>
<p class="text-immich-dark-gray/80 dark:text-immich-gray text-balance">
{$t('license_trial_info_2')}
<span class="text-immich-primary dark:text-immich-dark-primary font-semibold">
{$t('license_trial_info_3', { values: { accountAge } })}</span
>. {$t('license_trial_info_4')}
</p>
</div>
{/if}
<LicenseContent onActivate={onLicenseActivated} />
{/if}
</div>
</section>

View File

@ -18,6 +18,7 @@
import NotificationsSettings from '$lib/components/user-settings-page/notifications-settings.svelte'; import NotificationsSettings from '$lib/components/user-settings-page/notifications-settings.svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import DownloadSettings from '$lib/components/user-settings-page/download-settings.svelte'; import DownloadSettings from '$lib/components/user-settings-page/download-settings.svelte';
import LicenseSettings from '$lib/components/user-settings-page/license-settings.svelte';
export let keys: ApiKeyResponseDto[] = []; export let keys: ApiKeyResponseDto[] = [];
export let sessions: SessionResponseDto[] = []; export let sessions: SessionResponseDto[] = [];
@ -52,6 +53,14 @@
<DownloadSettings /> <DownloadSettings />
</SettingAccordion> </SettingAccordion>
<SettingAccordion
key="user-license-settings"
title={$t('user_license_settings')}
subtitle={$t('user_license_settings_description')}
>
<LicenseSettings />
</SettingAccordion>
<SettingAccordion key="memories" title={$t('memories')} subtitle={$t('memories_setting_description')}> <SettingAccordion key="memories" title={$t('memories')} subtitle={$t('memories_setting_description')}>
<MemoriesSettings /> <MemoriesSettings />
</SettingAccordion> </SettingAccordion>

View File

@ -34,6 +34,7 @@ export enum AppRoute {
MEMORY = '/memory', MEMORY = '/memory',
TRASH = '/trash', TRASH = '/trash',
PARTNERS = '/partners', PARTNERS = '/partners',
BUY = '/buy',
AUTH_LOGIN = '/auth/login', AUTH_LOGIN = '/auth/login',
AUTH_REGISTER = '/auth/register', AUTH_REGISTER = '/auth/register',
@ -309,3 +310,8 @@ export const langs = [
}, },
{ name: 'Development (keys only)', code: 'dev', loader: () => Promise.resolve({}) }, { name: 'Development (keys only)', code: 'dev', loader: () => Promise.resolve({}) },
]; ];
export enum ImmichLicense {
Client = 'immich-client',
Server = 'immich-server',
}

View File

@ -403,6 +403,7 @@
"bulk_delete_duplicates_confirmation": "Are you sure you want to bulk delete {count, plural, one {# duplicate asset} other {# duplicate assets}}? This will keep the largest asset of each group and permanently delete all other duplicates. You cannot undo this action!", "bulk_delete_duplicates_confirmation": "Are you sure you want to bulk delete {count, plural, one {# duplicate asset} other {# duplicate assets}}? This will keep the largest asset of each group and permanently delete all other duplicates. You cannot undo this action!",
"bulk_keep_duplicates_confirmation": "Are you sure you want to keep {count, plural, one {# duplicate asset} other {# duplicate assets}}? This will resolve all duplicate groups without deleting anything.", "bulk_keep_duplicates_confirmation": "Are you sure you want to keep {count, plural, one {# duplicate asset} other {# duplicate assets}}? This will resolve all duplicate groups without deleting anything.",
"bulk_trash_duplicates_confirmation": "Are you sure you want to bulk trash {count, plural, one {# duplicate asset} other {# duplicate assets}}? This will keep the largest asset of each group and trash all other duplicates.", "bulk_trash_duplicates_confirmation": "Are you sure you want to bulk trash {count, plural, one {# duplicate asset} other {# duplicate assets}}? This will keep the largest asset of each group and trash all other duplicates.",
"buy": "Purchase License",
"camera": "Camera", "camera": "Camera",
"camera_brand": "Camera brand", "camera_brand": "Camera brand",
"camera_model": "Camera model", "camera_model": "Camera model",
@ -742,6 +743,31 @@
"level": "Level", "level": "Level",
"library": "Library", "library": "Library",
"library_options": "Library options", "library_options": "Library options",
"license_account_info": "Your account is licensed",
"license_activated_subtitle": "Thank you for supporting Immich and open-source software",
"license_activated_title": "Your license has been successfully activated",
"license_button_activate": "Activate",
"license_button_buy": "Buy",
"license_button_buy_license": "Buy License",
"license_button_select": "Select",
"license_failed_activation": "Failed to activate license. Please check your email for the the correct license key!",
"license_individual_description_1": "1 license per user on any server",
"license_individual_title": "Individual License",
"license_info_licensed": "Licensed",
"license_info_unlicensed": "Unlicensed",
"license_input_suggestion": "Have a license? Enter the key below",
"license_license_subtitle": "Buy a license to support Immich",
"license_license_title": "LICENSE",
"license_lifetime_description": "Lifetime license",
"license_per_server": "Per server",
"license_per_user": "Per user",
"license_server_description_1": "1 license per server",
"license_server_description_2": "License for all users on the server",
"license_server_title": "Server License",
"license_trial_info_1": "You are running an Unlicensed version of Immich",
"license_trial_info_2": "You have been using Immich for approximately",
"license_trial_info_3": "{accountAge, plural, one {# day} other {# days}}",
"license_trial_info_4": "Please considering purchasing a license to support the continued development of the service",
"light": "Light", "light": "Light",
"like_deleted": "Like deleted", "like_deleted": "Like deleted",
"link_options": "Link options", "link_options": "Link options",
@ -1007,7 +1033,8 @@
"selected_count": "{count, plural, other {# selected}}", "selected_count": "{count, plural, other {# selected}}",
"send_message": "Send message", "send_message": "Send message",
"send_welcome_email": "Send welcome email", "send_welcome_email": "Send welcome email",
"server": "Server", "server_offline": "Server Offline",
"server_online": "Server Online",
"server_stats": "Server Stats", "server_stats": "Server Stats",
"server_version": "Server Version", "server_version": "Server Version",
"set": "Set", "set": "Set",
@ -1073,7 +1100,7 @@
"stop_photo_sharing": "Stop sharing your photos?", "stop_photo_sharing": "Stop sharing your photos?",
"stop_photo_sharing_description": "{partner} will no longer be able to access your photos.", "stop_photo_sharing_description": "{partner} will no longer be able to access your photos.",
"stop_sharing_photos_with_user": "Stop sharing your photos with this user", "stop_sharing_photos_with_user": "Stop sharing your photos with this user",
"storage": "Storage", "storage": "Storage space",
"storage_label": "Storage label", "storage_label": "Storage label",
"storage_usage": "{used} of {available} used", "storage_usage": "{used} of {available} used",
"submit": "Submit", "submit": "Submit",
@ -1136,6 +1163,8 @@
"use_custom_date_range": "Use custom date range instead", "use_custom_date_range": "Use custom date range instead",
"user": "User", "user": "User",
"user_id": "User ID", "user_id": "User ID",
"user_license_settings": "License",
"user_license_settings_description": "Manage your license",
"user_liked": "{user} liked {type, select, photo {this photo} video {this video} asset {this asset} other {it}}", "user_liked": "{user} liked {type, select, photo {this photo} video {this video} asset {this asset} other {it}}",
"user_role_set": "Set {user} as {role}", "user_role_set": "Set {user} as {role}",
"user_usage_detail": "User usage detail", "user_usage_detail": "User usage detail",

View File

@ -0,0 +1,18 @@
import { writable } from 'svelte/store';
function createLicenseStore() {
const isLicenseActivated = writable(false);
function setLicenseStatus(status: boolean) {
isLicenseActivated.set(status);
}
return {
isLicenseActivated: {
subscribe: isLicenseActivated.subscribe,
},
setLicenseStatus,
};
}
export const licenseStore = createLicenseStore();

View File

@ -1,3 +1,4 @@
import { licenseStore } from '$lib/stores/license.store';
import { type UserAdminResponseDto, type UserPreferencesResponseDto } from '@immich/sdk'; import { type UserAdminResponseDto, type UserPreferencesResponseDto } from '@immich/sdk';
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
@ -11,4 +12,5 @@ export const preferences = writable<UserPreferencesResponseDto>();
export const resetSavedUser = () => { export const resetSavedUser = () => {
user.set(undefined as unknown as UserAdminResponseDto); user.set(undefined as unknown as UserAdminResponseDto);
preferences.set(undefined as unknown as UserPreferencesResponseDto); preferences.set(undefined as unknown as UserPreferencesResponseDto);
licenseStore.setLicenseStatus(false);
}; };

View File

@ -1,8 +1,10 @@
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { licenseStore } from '$lib/stores/license.store';
import { serverInfo } from '$lib/stores/server-info.store'; import { serverInfo } from '$lib/stores/server-info.store';
import { preferences as preferences$, user as user$ } from '$lib/stores/user.store'; import { preferences as preferences$, user as user$ } from '$lib/stores/user.store';
import { getMyPreferences, getMyUser, getStorage } from '@immich/sdk'; import { getAboutInfo, getMyPreferences, getMyUser, getStorage } from '@immich/sdk';
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import { DateTime } from 'luxon';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { AppRoute } from '../constants'; import { AppRoute } from '../constants';
@ -15,10 +17,17 @@ export const loadUser = async () => {
try { try {
let user = get(user$); let user = get(user$);
let preferences = get(preferences$); let preferences = get(preferences$);
let serverInfo;
if ((!user || !preferences) && hasAuthCookie()) { if ((!user || !preferences) && hasAuthCookie()) {
[user, preferences] = await Promise.all([getMyUser(), getMyPreferences()]); [user, preferences, serverInfo] = await Promise.all([getMyUser(), getMyPreferences(), getAboutInfo()]);
user$.set(user); user$.set(user);
preferences$.set(preferences); preferences$.set(preferences);
// Check for license status
if (serverInfo.licensed || user.license?.activatedAt) {
licenseStore.setLicenseStatus(true);
}
} }
return user; return user;
} catch { } catch {
@ -64,3 +73,17 @@ export const requestServerInfo = async () => {
serverInfo.set(data); serverInfo.set(data);
} }
}; };
export const getAccountAge = (): number => {
const user = get(user$);
if (!user) {
return 0;
}
const createdDate = DateTime.fromISO(user.createdAt);
const now = DateTime.now();
const accountAge = now.diff(createdDate, 'days').days.toFixed(0);
return Number(accountAge);
};

View File

@ -0,0 +1,26 @@
import { PUBLIC_IMMICH_BUY_HOST, PUBLIC_IMMICH_PAY_HOST } from '$env/static/public';
import type { ImmichLicense } from '$lib/constants';
import { serverConfig } from '$lib/stores/server-config.store';
import { setServerLicense, setUserLicense, type LicenseResponseDto } from '@immich/sdk';
import { get } from 'svelte/store';
export const activateLicense = async (licenseKey: string, activationKey: string): Promise<LicenseResponseDto> => {
const isServerKey = licenseKey.search('IMSV') !== -1;
const licenseKeyDto = { licenseKey, activationKey };
return isServerKey ? setServerLicense({ licenseKeyDto }) : setUserLicense({ licenseKeyDto });
};
export const getActivationKey = async (licenseKey: string): Promise<string> => {
const response = await fetch(new URL(`/api/v1/activate/${licenseKey}`, PUBLIC_IMMICH_PAY_HOST).href);
if (!response.ok) {
throw new Error('Failed to fetch activation key');
}
return response.text();
};
export const getLicenseLink = (license: ImmichLicense) => {
const url = new URL('/', PUBLIC_IMMICH_BUY_HOST);
url.searchParams.append('productId', license);
url.searchParams.append('instanceUrl', get(serverConfig).externalDomain || window.origin);
return url.href;
};

View File

@ -0,0 +1,53 @@
<script lang="ts">
import { goto } from '$app/navigation';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import LicenseActivationSuccess from '$lib/components/shared-components/license/license-activation-success.svelte';
import LicenseContent from '$lib/components/shared-components/license/license-content.svelte';
import { AppRoute } from '$lib/constants';
import { user } from '$lib/stores/user.store';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
import Icon from '$lib/components/elements/icon.svelte';
import { mdiAlertCircleOutline, mdiLicense } from '@mdi/js';
import { licenseStore } from '$lib/stores/license.store';
export let data: PageData;
let showLicenseActivated = false;
const { isLicenseActivated } = licenseStore;
</script>
<UserPageLayout title={$t('buy')}>
<section class="mx-4 flex place-content-center">
<div class={`w-full ${$user.isAdmin ? 'max-w-3xl' : 'max-w-xl'}`}>
{#if data.isActivated === false}
<div
class="bg-red-100 text-red-700 px-4 py-3 rounded-md flex place-items-center place-content-center gap-2"
role="alert"
>
<Icon path={mdiAlertCircleOutline} size="18" />
<p>{$t('license_failed_activation')}</p>
</div>
{/if}
{#if $isLicenseActivated}
<div
class="bg-immich-primary/10 text-immich-primary px-4 py-3 rounded-md flex place-items-center place-content-center gap-2 mb-5 dark:text-black dark:bg-immich-dark-primary"
role="alert"
>
<Icon path={mdiLicense} size="24" />
<p>{$t('license_account_info')}</p>
</div>
{/if}
{#if showLicenseActivated || data.isActivated === true}
<LicenseActivationSuccess onDone={() => goto(AppRoute.PHOTOS, { replaceState: false })} />
{:else}
<LicenseContent
onActivate={() => {
showLicenseActivated = true;
}}
/>
{/if}
</div>
</section>
</UserPageLayout>

View File

@ -0,0 +1,38 @@
import { licenseStore } from '$lib/stores/license.store';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { activateLicense, getActivationKey } from '$lib/utils/license-utils';
import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate();
const $t = await getFormatter();
const licenseKey = url.searchParams.get('licenseKey');
let activationKey = url.searchParams.get('activationKey');
let isActivated: boolean | undefined = undefined;
try {
if (licenseKey && !activationKey) {
activationKey = await getActivationKey(licenseKey);
}
if (licenseKey && activationKey) {
const response = await activateLicense(licenseKey, activationKey);
if (response.activatedAt !== '') {
isActivated = true;
licenseStore.setLicenseStatus(true);
}
}
} catch (error) {
isActivated = false;
console.log('error navigating to /buy', error);
}
return {
meta: {
title: $t('buy'),
},
isActivated,
};
}) satisfies PageLoad;

View File

@ -22,7 +22,6 @@
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
let showNavigationLoadingBar = false; let showNavigationLoadingBar = false;
$: changeTheme($colorTheme); $: changeTheme($colorTheme);
$: if ($user) { $: if ($user) {

View File

@ -7,11 +7,11 @@ export const load = (({ url }) => {
HOME = 'home', HOME = 'home',
UNSUBSCRIBE = 'unsubscribe', UNSUBSCRIBE = 'unsubscribe',
VIEW_ASSET = 'view_asset', VIEW_ASSET = 'view_asset',
ACTIVATE_LICENSE = 'activate_license',
} }
const queryParams = url.searchParams; const queryParams = url.searchParams;
const target = queryParams.get('target') as LinkTarget; const target = queryParams.get('target') as LinkTarget;
switch (target) { switch (target) {
case LinkTarget.HOME: { case LinkTarget.HOME: {
return redirect(302, AppRoute.PHOTOS); return redirect(302, AppRoute.PHOTOS);
@ -28,6 +28,26 @@ export const load = (({ url }) => {
} }
break; break;
} }
case LinkTarget.ACTIVATE_LICENSE: {
// https://my.immich.app/link?target=activate_license&licenseKey=IMCL-76S5-B4KG-4HXA-KRQF-C1G1-7PJ6-9V9V-7WQH
// https://my.immich.app/link?target=activate_license&licenseKey=IMCL-9XC3-T4S3-37BU-GGJ5-8MWP-F2Y1-BGEX-AQTF
const licenseKey = queryParams.get('licenseKey');
const activationKey = queryParams.get('activationKey');
const redirectUrl = new URL(AppRoute.BUY, url.origin);
if (licenseKey) {
redirectUrl.searchParams.append('licenseKey', licenseKey);
if (activationKey) {
redirectUrl.searchParams.append('activationKey', activationKey);
}
return redirect(302, redirectUrl);
}
break;
}
} }
return redirect(302, AppRoute.PHOTOS); return redirect(302, AppRoute.PHOTOS);

View File

@ -1,5 +1,11 @@
import adapter from '@sveltejs/adapter-static'; import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
import dotenv from 'dotenv';
dotenv.config();
process.env.PUBLIC_IMMICH_BUY_HOST = process.env.PUBLIC_IMMICH_BUY_HOST || 'https://buy.immich.app';
process.env.PUBLIC_IMMICH_PAY_HOST = process.env.PUBLIC_IMMICH_PAY_HOST || 'https://pay.futo.org';
/** @type {import('@sveltejs/kit').Config} */ /** @type {import('@sveltejs/kit').Config} */
const config = { const config = {