From ef0e1a81b912c41ad275b0b99ae4ba605f8c49d8 Mon Sep 17 00:00:00 2001 From: Alex <alex.tran1502@gmail.com> Date: Thu, 18 Jul 2024 10:56:27 -0500 Subject: [PATCH] feat(web): license UI (#11182) --- .dockerignore | 1 + .../2024/immich-core-team-goes-fulltime.mdx | 2 +- docs/blog/2024/immich-licensing.mdx | 91 +++++++++ e2e/package-lock.json | 6 +- e2e/src/api/specs/asset.e2e-spec.ts | 2 +- server/src/controllers/server.controller.ts | 2 +- server/src/emails/license.email.tsx | 186 ++++++++++++++++++ server/src/services/server.service.ts | 2 +- web/package-lock.json | 13 ++ web/package.json | 1 + web/src/app.d.ts | 5 + .../full-screen-modal.svelte | 2 +- .../license/license-activation-success.svelte | 18 ++ .../license/license-content.svelte | 70 +++++++ .../license/license-modal.svelte | 25 +++ .../license/server-license-card.svelte | 44 +++++ .../license/user-license-card.svelte | 39 ++++ .../navigation-bar/navigation-bar.svelte | 1 + .../shared-components/portal/portal.svelte | 18 ++ .../side-bar/admin-side-bar.svelte | 6 +- .../side-bar/bottom-info.svelte | 17 ++ .../side-bar/license-info.svelte | 92 +++++++++ .../side-bar/server-status.svelte | 49 +++++ .../side-bar/side-bar.svelte | 7 +- .../side-bar/storage-space.svelte | 82 ++++++++ .../shared-components/status-box.svelte | 125 ------------ .../license-settings.svelte | 158 +++++++++++++++ .../user-settings-list.svelte | 9 + web/src/lib/constants.ts | 6 + web/src/lib/i18n/en.json | 33 +++- web/src/lib/stores/license.store.ts | 18 ++ web/src/lib/stores/user.store.ts | 2 + web/src/lib/utils/auth.ts | 27 ++- web/src/lib/utils/license-utils.ts | 26 +++ web/src/routes/(user)/buy/+page.svelte | 53 +++++ web/src/routes/(user)/buy/+page.ts | 38 ++++ web/src/routes/+layout.svelte | 1 - web/src/routes/link/+page.ts | 22 ++- web/svelte.config.js | 6 + 39 files changed, 1157 insertions(+), 148 deletions(-) create mode 100644 docs/blog/2024/immich-licensing.mdx create mode 100644 server/src/emails/license.email.tsx create mode 100644 web/src/lib/components/shared-components/license/license-activation-success.svelte create mode 100644 web/src/lib/components/shared-components/license/license-content.svelte create mode 100644 web/src/lib/components/shared-components/license/license-modal.svelte create mode 100644 web/src/lib/components/shared-components/license/server-license-card.svelte create mode 100644 web/src/lib/components/shared-components/license/user-license-card.svelte create mode 100644 web/src/lib/components/shared-components/side-bar/bottom-info.svelte create mode 100644 web/src/lib/components/shared-components/side-bar/license-info.svelte create mode 100644 web/src/lib/components/shared-components/side-bar/server-status.svelte create mode 100644 web/src/lib/components/shared-components/side-bar/storage-space.svelte delete mode 100644 web/src/lib/components/shared-components/status-box.svelte create mode 100644 web/src/lib/components/user-settings-page/license-settings.svelte create mode 100644 web/src/lib/stores/license.store.ts create mode 100644 web/src/lib/utils/license-utils.ts create mode 100644 web/src/routes/(user)/buy/+page.svelte create mode 100644 web/src/routes/(user)/buy/+page.ts diff --git a/.dockerignore b/.dockerignore index 7559cf366a..a3096e7d40 100644 --- a/.dockerignore +++ b/.dockerignore @@ -29,3 +29,4 @@ web/node_modules/ web/coverage/ web/.svelte-kit web/build/ +web/.env diff --git a/docs/blog/2024/immich-core-team-goes-fulltime.mdx b/docs/blog/2024/immich-core-team-goes-fulltime.mdx index 5edd39ad78..0cba2b467c 100644 --- a/docs/blog/2024/immich-core-team-goes-fulltime.mdx +++ b/docs/blog/2024/immich-core-team-goes-fulltime.mdx @@ -1,7 +1,7 @@ --- title: The Immich core team goes full-time authors: [alextran] -tags: [update, announcement, futo] +tags: [update, announcement, FUTO] date: 2024-05-01T00:00 --- diff --git a/docs/blog/2024/immich-licensing.mdx b/docs/blog/2024/immich-licensing.mdx new file mode 100644 index 0000000000..4b4272e163 --- /dev/null +++ b/docs/blog/2024/immich-licensing.mdx @@ -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. diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 9a57519d2d..1c6e65cf84 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -56,12 +56,12 @@ "devDependencies": { "@immich/sdk": "file:../open-api/typescript-sdk", "@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/mock-fs": "^4.13.1", "@types/node": "^20.14.10", - "@typescript-eslint/eslint-plugin": "^7.16.0", - "@typescript-eslint/parser": "^7.16.0", + "@typescript-eslint/eslint-plugin": "^7.0.0", + "@typescript-eslint/parser": "^7.0.0", "@vitest/coverage-v8": "^1.2.2", "byte-size": "^8.1.1", "cli-progress": "^3.12.0", diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index 694114aed5..a5ba40e148 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -507,7 +507,7 @@ describe('/asset', () => { 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) .put(`/assets/${user1Assets[0].id}`) .set('Authorization', `Bearer ${user1.accessToken}`) diff --git a/server/src/controllers/server.controller.ts b/server/src/controllers/server.controller.ts index b98ca38a80..009c36c793 100644 --- a/server/src/controllers/server.controller.ts +++ b/server/src/controllers/server.controller.ts @@ -95,7 +95,7 @@ export class ServerController { @Get('license') @Authenticated({ admin: true }) - getServerLicense(): Promise<LicenseKeyDto | null> { + getServerLicense(): Promise<LicenseResponseDto | null> { return this.service.getLicense(); } } diff --git a/server/src/emails/license.email.tsx b/server/src/emails/license.email.tsx new file mode 100644 index 0000000000..9c6c42a152 --- /dev/null +++ b/server/src/emails/license.email.tsx @@ -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', +}; diff --git a/server/src/services/server.service.ts b/server/src/services/server.service.ts index b477f0f35c..1aaf85b1ba 100644 --- a/server/src/services/server.service.ts +++ b/server/src/services/server.service.ts @@ -164,7 +164,7 @@ export class ServerService implements OnEvents { await this.systemMetadataRepository.delete(SystemMetadataKey.LICENSE); } - async getLicense(): Promise<LicenseKeyDto | null> { + async getLicense(): Promise<LicenseResponseDto | null> { return this.systemMetadataRepository.get(SystemMetadataKey.LICENSE); } diff --git a/web/package-lock.json b/web/package-lock.json index 02513f110a..40bb5afb5f 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -47,6 +47,7 @@ "@typescript-eslint/parser": "^7.1.0", "@vitest/coverage-v8": "^1.3.1", "autoprefixer": "^10.4.17", + "dotenv": "^16.4.5", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", "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", "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": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", diff --git a/web/package.json b/web/package.json index d202fa3a39..a2a1a91241 100644 --- a/web/package.json +++ b/web/package.json @@ -40,6 +40,7 @@ "@typescript-eslint/parser": "^7.1.0", "@vitest/coverage-v8": "^1.3.1", "autoprefixer": "^10.4.17", + "dotenv": "^16.4.5", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-svelte": "^2.35.1", diff --git a/web/src/app.d.ts b/web/src/app.d.ts index 241a579fc7..ae6c5b559b 100644 --- a/web/src/app.d.ts +++ b/web/src/app.d.ts @@ -27,3 +27,8 @@ declare namespace svelteHTML { 'on:zoomImage'?: () => void; } } + +declare module '$env/static/public' { + export const PUBLIC_IMMICH_PAY_HOST: string; + export const PUBLIC_IMMICH_BUY_HOST: string; +} diff --git a/web/src/lib/components/shared-components/full-screen-modal.svelte b/web/src/lib/components/shared-components/full-screen-modal.svelte index bc1253a546..be407decde 100644 --- a/web/src/lib/components/shared-components/full-screen-modal.svelte +++ b/web/src/lib/components/shared-components/full-screen-modal.svelte @@ -39,7 +39,7 @@ } else if (width === 'narrow') { modalWidth = 'w-[28rem]'; } else { - modalWidth = 'sm:max-w-lg'; + modalWidth = 'sm:max-w-4xl'; } } </script> diff --git a/web/src/lib/components/shared-components/license/license-activation-success.svelte b/web/src/lib/components/shared-components/license/license-activation-success.svelte new file mode 100644 index 0000000000..f77e854aec --- /dev/null +++ b/web/src/lib/components/shared-components/license/license-activation-success.svelte @@ -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> diff --git a/web/src/lib/components/shared-components/license/license-content.svelte b/web/src/lib/components/shared-components/license/license-content.svelte new file mode 100644 index 0000000000..e5f780265d --- /dev/null +++ b/web/src/lib/components/shared-components/license/license-content.svelte @@ -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> diff --git a/web/src/lib/components/shared-components/license/license-modal.svelte b/web/src/lib/components/shared-components/license/license-modal.svelte new file mode 100644 index 0000000000..9f7e23c5d1 --- /dev/null +++ b/web/src/lib/components/shared-components/license/license-modal.svelte @@ -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> diff --git a/web/src/lib/components/shared-components/license/server-license-card.svelte b/web/src/lib/components/shared-components/license/server-license-card.svelte new file mode 100644 index 0000000000..bfdbb3a665 --- /dev/null +++ b/web/src/lib/components/shared-components/license/server-license-card.svelte @@ -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> diff --git a/web/src/lib/components/shared-components/license/user-license-card.svelte b/web/src/lib/components/shared-components/license/user-license-card.svelte new file mode 100644 index 0000000000..96f30c6857 --- /dev/null +++ b/web/src/lib/components/shared-components/license/user-license-card.svelte @@ -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> diff --git a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte index c3726c967e..e0c8ff7457 100644 --- a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte +++ b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte @@ -31,6 +31,7 @@ const logOut = async () => { const { redirectUri } = await logout(); + if (redirectUri.startsWith('/')) { await goto(redirectUri); } else { diff --git a/web/src/lib/components/shared-components/portal/portal.svelte b/web/src/lib/components/shared-components/portal/portal.svelte index 924e5f0c6b..7a9e577083 100644 --- a/web/src/lib/components/shared-components/portal/portal.svelte +++ b/web/src/lib/components/shared-components/portal/portal.svelte @@ -45,6 +45,24 @@ } </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"> /** * DOM Element or CSS Selector diff --git a/web/src/lib/components/shared-components/side-bar/admin-side-bar.svelte b/web/src/lib/components/shared-components/side-bar/admin-side-bar.svelte index a478cb2fdc..2c2096338e 100644 --- a/web/src/lib/components/shared-components/side-bar/admin-side-bar.svelte +++ b/web/src/lib/components/shared-components/side-bar/admin-side-bar.svelte @@ -1,7 +1,7 @@ <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 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 { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiSync, mdiTools } from '@mdi/js'; import { t } from 'svelte-i18n'; @@ -17,7 +17,5 @@ <SideBarLink title={$t('repair')} routeId={AppRoute.ADMIN_REPAIR} icon={mdiTools} preloadData={false} /> </nav> - <div class="mb-6 mt-auto"> - <StatusBox /> - </div> + <BottomInfo /> </SideBarSection> diff --git a/web/src/lib/components/shared-components/side-bar/bottom-info.svelte b/web/src/lib/components/shared-components/side-bar/bottom-info.svelte new file mode 100644 index 0000000000..c55b5460ca --- /dev/null +++ b/web/src/lib/components/shared-components/side-bar/bottom-info.svelte @@ -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> diff --git a/web/src/lib/components/shared-components/side-bar/license-info.svelte b/web/src/lib/components/shared-components/side-bar/license-info.svelte new file mode 100644 index 0000000000..62e793a27f --- /dev/null +++ b/web/src/lib/components/shared-components/side-bar/license-info.svelte @@ -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> diff --git a/web/src/lib/components/shared-components/side-bar/server-status.svelte b/web/src/lib/components/shared-components/side-bar/server-status.svelte new file mode 100644 index 0000000000..83ed98584a --- /dev/null +++ b/web/src/lib/components/shared-components/side-bar/server-status.svelte @@ -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> diff --git a/web/src/lib/components/shared-components/side-bar/side-bar.svelte b/web/src/lib/components/shared-components/side-bar/side-bar.svelte index a0355dd407..6688a427dd 100644 --- a/web/src/lib/components/shared-components/side-bar/side-bar.svelte +++ b/web/src/lib/components/shared-components/side-bar/side-bar.svelte @@ -21,12 +21,12 @@ mdiToolbox, mdiToolboxOutline, } from '@mdi/js'; - import StatusBox from '../status-box.svelte'; import SideBarSection from './side-bar-section.svelte'; import SideBarLink from './side-bar-link.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 { t } from 'svelte-i18n'; + import BottomInfo from '$lib/components/shared-components/side-bar/bottom-info.svelte'; let isArchiveSelected: boolean; let isFavoritesSelected: boolean; @@ -136,8 +136,5 @@ {/if} </nav> - <!-- Status Box --> - <div class="mb-6 mt-auto"> - <StatusBox /> - </div> + <BottomInfo /> </SideBarSection> diff --git a/web/src/lib/components/shared-components/side-bar/storage-space.svelte b/web/src/lib/components/shared-components/side-bar/storage-space.svelte new file mode 100644 index 0000000000..df6d15e854 --- /dev/null +++ b/web/src/lib/components/shared-components/side-bar/storage-space.svelte @@ -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> diff --git a/web/src/lib/components/shared-components/status-box.svelte b/web/src/lib/components/shared-components/status-box.svelte deleted file mode 100644 index 6427f1e08c..0000000000 --- a/web/src/lib/components/shared-components/status-box.svelte +++ /dev/null @@ -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> diff --git a/web/src/lib/components/user-settings-page/license-settings.svelte b/web/src/lib/components/user-settings-page/license-settings.svelte new file mode 100644 index 0000000000..af61796784 --- /dev/null +++ b/web/src/lib/components/user-settings-page/license-settings.svelte @@ -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> diff --git a/web/src/lib/components/user-settings-page/user-settings-list.svelte b/web/src/lib/components/user-settings-page/user-settings-list.svelte index f6dc61ef04..db81273377 100644 --- a/web/src/lib/components/user-settings-page/user-settings-list.svelte +++ b/web/src/lib/components/user-settings-page/user-settings-list.svelte @@ -18,6 +18,7 @@ import NotificationsSettings from '$lib/components/user-settings-page/notifications-settings.svelte'; import { t } from 'svelte-i18n'; 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 sessions: SessionResponseDto[] = []; @@ -52,6 +53,14 @@ <DownloadSettings /> </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')}> <MemoriesSettings /> </SettingAccordion> diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index 0f7eb3400f..7e82ef75bc 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -34,6 +34,7 @@ export enum AppRoute { MEMORY = '/memory', TRASH = '/trash', PARTNERS = '/partners', + BUY = '/buy', AUTH_LOGIN = '/auth/login', AUTH_REGISTER = '/auth/register', @@ -309,3 +310,8 @@ export const langs = [ }, { name: 'Development (keys only)', code: 'dev', loader: () => Promise.resolve({}) }, ]; + +export enum ImmichLicense { + Client = 'immich-client', + Server = 'immich-server', +} diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index 08fbceadd7..0d108a2eb4 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -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_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.", + "buy": "Purchase License", "camera": "Camera", "camera_brand": "Camera brand", "camera_model": "Camera model", @@ -742,6 +743,31 @@ "level": "Level", "library": "Library", "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", "like_deleted": "Like deleted", "link_options": "Link options", @@ -1007,7 +1033,8 @@ "selected_count": "{count, plural, other {# selected}}", "send_message": "Send message", "send_welcome_email": "Send welcome email", - "server": "Server", + "server_offline": "Server Offline", + "server_online": "Server Online", "server_stats": "Server Stats", "server_version": "Server Version", "set": "Set", @@ -1073,7 +1100,7 @@ "stop_photo_sharing": "Stop sharing 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", - "storage": "Storage", + "storage": "Storage space", "storage_label": "Storage label", "storage_usage": "{used} of {available} used", "submit": "Submit", @@ -1136,6 +1163,8 @@ "use_custom_date_range": "Use custom date range instead", "user": "User", "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_role_set": "Set {user} as {role}", "user_usage_detail": "User usage detail", diff --git a/web/src/lib/stores/license.store.ts b/web/src/lib/stores/license.store.ts new file mode 100644 index 0000000000..aecfae31bb --- /dev/null +++ b/web/src/lib/stores/license.store.ts @@ -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(); diff --git a/web/src/lib/stores/user.store.ts b/web/src/lib/stores/user.store.ts index 8d422d3704..920ec4047f 100644 --- a/web/src/lib/stores/user.store.ts +++ b/web/src/lib/stores/user.store.ts @@ -1,3 +1,4 @@ +import { licenseStore } from '$lib/stores/license.store'; import { type UserAdminResponseDto, type UserPreferencesResponseDto } from '@immich/sdk'; import { writable } from 'svelte/store'; @@ -11,4 +12,5 @@ export const preferences = writable<UserPreferencesResponseDto>(); export const resetSavedUser = () => { user.set(undefined as unknown as UserAdminResponseDto); preferences.set(undefined as unknown as UserPreferencesResponseDto); + licenseStore.setLicenseStatus(false); }; diff --git a/web/src/lib/utils/auth.ts b/web/src/lib/utils/auth.ts index df5c9bc46a..78b613299b 100644 --- a/web/src/lib/utils/auth.ts +++ b/web/src/lib/utils/auth.ts @@ -1,8 +1,10 @@ import { browser } from '$app/environment'; +import { licenseStore } from '$lib/stores/license.store'; import { serverInfo } from '$lib/stores/server-info.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 { DateTime } from 'luxon'; import { get } from 'svelte/store'; import { AppRoute } from '../constants'; @@ -15,10 +17,17 @@ export const loadUser = async () => { try { let user = get(user$); let preferences = get(preferences$); + let serverInfo; + if ((!user || !preferences) && hasAuthCookie()) { - [user, preferences] = await Promise.all([getMyUser(), getMyPreferences()]); + [user, preferences, serverInfo] = await Promise.all([getMyUser(), getMyPreferences(), getAboutInfo()]); user$.set(user); preferences$.set(preferences); + + // Check for license status + if (serverInfo.licensed || user.license?.activatedAt) { + licenseStore.setLicenseStatus(true); + } } return user; } catch { @@ -64,3 +73,17 @@ export const requestServerInfo = async () => { 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); +}; diff --git a/web/src/lib/utils/license-utils.ts b/web/src/lib/utils/license-utils.ts new file mode 100644 index 0000000000..54be1559d9 --- /dev/null +++ b/web/src/lib/utils/license-utils.ts @@ -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; +}; diff --git a/web/src/routes/(user)/buy/+page.svelte b/web/src/routes/(user)/buy/+page.svelte new file mode 100644 index 0000000000..4f0b0644c2 --- /dev/null +++ b/web/src/routes/(user)/buy/+page.svelte @@ -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> diff --git a/web/src/routes/(user)/buy/+page.ts b/web/src/routes/(user)/buy/+page.ts new file mode 100644 index 0000000000..9c34573d5d --- /dev/null +++ b/web/src/routes/(user)/buy/+page.ts @@ -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; diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index fbbc58664d..a6661f88cd 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -22,7 +22,6 @@ import { t } from 'svelte-i18n'; let showNavigationLoadingBar = false; - $: changeTheme($colorTheme); $: if ($user) { diff --git a/web/src/routes/link/+page.ts b/web/src/routes/link/+page.ts index f412c850b7..f875b00ed2 100644 --- a/web/src/routes/link/+page.ts +++ b/web/src/routes/link/+page.ts @@ -7,11 +7,11 @@ export const load = (({ url }) => { HOME = 'home', UNSUBSCRIBE = 'unsubscribe', VIEW_ASSET = 'view_asset', + ACTIVATE_LICENSE = 'activate_license', } const queryParams = url.searchParams; const target = queryParams.get('target') as LinkTarget; - switch (target) { case LinkTarget.HOME: { return redirect(302, AppRoute.PHOTOS); @@ -28,6 +28,26 @@ export const load = (({ url }) => { } 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); diff --git a/web/svelte.config.js b/web/svelte.config.js index 76a9c2e55b..a66f798f31 100644 --- a/web/svelte.config.js +++ b/web/svelte.config.js @@ -1,5 +1,11 @@ import adapter from '@sveltejs/adapter-static'; 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} */ const config = {