From 8af914b0be8e58996113e24e06544be7e47eeda5 Mon Sep 17 00:00:00 2001 From: suiramdev Date: Wed, 23 Sep 2026 17:10:55 +0200 Subject: [PATCH 1/8] feat: let an operator claim an instance and set its providers from the app --- .docker/dockerfile.dev | 1 + .../docs/next/contributing/data-model.mdx | 43 ++- .../fumadocs/content/docs/next/quickstart.mdx | 37 ++- .../next/self-hosting/backup-and-restore.mdx | 11 +- .../docs/next/self-hosting/bank-providers.mdx | 2 + .../docs/next/self-hosting/configuration.mdx | 13 + .../content/docs/next/self-hosting/email.mdx | 2 + .../content/docs/next/self-hosting/index.mdx | 37 ++- .../content/docs/next/self-hosting/meta.json | 1 + .../docs/next/self-hosting/security.mdx | 24 +- .../content/docs/next/self-hosting/setup.mdx | 154 ++++++++++ apps/server/package.json | 1 + apps/server/src/index.ts | 10 + apps/web/messages/en.json | 71 ++++- apps/web/messages/fr.json | 71 ++++- apps/web/src/app/api/get-instance.ts | 34 +++ apps/web/src/app/routes/__root.tsx | 2 + apps/web/src/app/routes/_auth/route.tsx | 6 +- apps/web/src/app/routes/login.tsx | 12 +- apps/web/src/app/routes/onboarding.tsx | 6 +- apps/web/src/app/routes/setup.tsx | 12 + .../onboarding/ui/bank-connection-step.tsx | 5 +- .../onboarding/ui/country-selection-step.tsx | 5 +- .../pages/onboarding/ui/onboarding-wizard.tsx | 166 +++-------- apps/web/src/pages/setup/index.ts | 1 + apps/web/src/pages/setup/model/labels.ts | 77 +++++ apps/web/src/pages/setup/model/use-setup.ts | 151 ++++++++++ apps/web/src/pages/setup/ui/claim-step.tsx | 51 ++++ apps/web/src/pages/setup/ui/finish-step.tsx | 84 ++++++ apps/web/src/pages/setup/ui/provider-step.tsx | 216 ++++++++++++++ apps/web/src/pages/setup/ui/setup-field.tsx | 108 +++++++ apps/web/src/pages/setup/ui/setup-page.tsx | 132 +++++++++ .../pages/setup/ui/setup-wizard-skeleton.tsx | 41 +++ apps/web/src/shared/ui/wizard-shell.tsx | 117 ++++++++ .../ui/wizard-step-header.tsx} | 6 +- .../ui/wizard-stepper.tsx} | 40 +-- bun.lock | 22 ++ docs/engineering/platform.md | 15 + packages/api/package.json | 2 + packages/api/src/index.ts | 20 ++ packages/api/src/instance/merge.test.ts | 133 +++++++++ packages/api/src/instance/merge.ts | 52 ++++ packages/api/src/instance/probes.ts | 79 +++++ .../src/providers/enable-banking/client.ts | 16 +- .../api/src/providers/enable-banking/probe.ts | 59 ++++ packages/api/src/providers/powens/client.ts | 14 +- packages/api/src/providers/powens/probe.ts | 93 ++++++ packages/api/src/providers/registry.ts | 4 +- packages/api/src/routers/index.ts | 2 + packages/api/src/routers/instance.ts | 278 ++++++++++++++++++ packages/auth/src/policy.ts | 2 + .../migration.sql | 28 ++ packages/db/prisma/schema/auth.prisma | 1 + packages/db/prisma/schema/instance.prisma | 36 +++ packages/email/package.json | 1 + packages/email/src/probe.ts | 105 +++++++ packages/email/src/registry.ts | 25 +- packages/instance-config/package.json | 27 ++ packages/instance-config/src/candidate.ts | 57 ++++ packages/instance-config/src/claim.ts | 101 +++++++ packages/instance-config/src/index.ts | 40 +++ packages/instance-config/src/integrations.ts | 124 ++++++++ packages/instance-config/src/probe.ts | 33 +++ packages/instance-config/src/resolve.ts | 46 +++ packages/instance-config/src/seal.test.ts | 65 ++++ packages/instance-config/src/seal.ts | 81 +++++ packages/instance-config/src/store.ts | 85 ++++++ packages/instance-config/tsconfig.json | 11 + 68 files changed, 3184 insertions(+), 223 deletions(-) create mode 100644 apps/fumadocs/content/docs/next/self-hosting/setup.mdx create mode 100644 apps/web/src/app/api/get-instance.ts create mode 100644 apps/web/src/app/routes/setup.tsx create mode 100644 apps/web/src/pages/setup/index.ts create mode 100644 apps/web/src/pages/setup/model/labels.ts create mode 100644 apps/web/src/pages/setup/model/use-setup.ts create mode 100644 apps/web/src/pages/setup/ui/claim-step.tsx create mode 100644 apps/web/src/pages/setup/ui/finish-step.tsx create mode 100644 apps/web/src/pages/setup/ui/provider-step.tsx create mode 100644 apps/web/src/pages/setup/ui/setup-field.tsx create mode 100644 apps/web/src/pages/setup/ui/setup-page.tsx create mode 100644 apps/web/src/pages/setup/ui/setup-wizard-skeleton.tsx create mode 100644 apps/web/src/shared/ui/wizard-shell.tsx rename apps/web/src/{pages/onboarding/ui/onboarding-step-header.tsx => shared/ui/wizard-step-header.tsx} (69%) rename apps/web/src/{pages/onboarding/ui/onboarding-stepper.tsx => shared/ui/wizard-stepper.tsx} (81%) create mode 100644 packages/api/src/instance/merge.test.ts create mode 100644 packages/api/src/instance/merge.ts create mode 100644 packages/api/src/instance/probes.ts create mode 100644 packages/api/src/providers/enable-banking/probe.ts create mode 100644 packages/api/src/providers/powens/probe.ts create mode 100644 packages/api/src/routers/instance.ts create mode 100644 packages/db/prisma/migrations/20260922120000_instance_configuration/migration.sql create mode 100644 packages/db/prisma/schema/instance.prisma create mode 100644 packages/email/src/probe.ts create mode 100644 packages/instance-config/package.json create mode 100644 packages/instance-config/src/candidate.ts create mode 100644 packages/instance-config/src/claim.ts create mode 100644 packages/instance-config/src/index.ts create mode 100644 packages/instance-config/src/integrations.ts create mode 100644 packages/instance-config/src/probe.ts create mode 100644 packages/instance-config/src/resolve.ts create mode 100644 packages/instance-config/src/seal.test.ts create mode 100644 packages/instance-config/src/seal.ts create mode 100644 packages/instance-config/src/store.ts create mode 100644 packages/instance-config/tsconfig.json diff --git a/.docker/dockerfile.dev b/.docker/dockerfile.dev index e8bed392..b72e629d 100644 --- a/.docker/dockerfile.dev +++ b/.docker/dockerfile.dev @@ -15,6 +15,7 @@ COPY packages/config/tsconfig.base.json ./packages/config/ COPY packages/db/package.json ./packages/db/ COPY packages/email/package.json ./packages/email/ COPY packages/env/package.json ./packages/env/ +COPY packages/instance-config/package.json ./packages/instance-config/ COPY packages/ui/package.json ./packages/ui/ RUN bun install --frozen-lockfile --ignore-scripts diff --git a/apps/fumadocs/content/docs/next/contributing/data-model.mdx b/apps/fumadocs/content/docs/next/contributing/data-model.mdx index 643e523b..4a0a8d59 100644 --- a/apps/fumadocs/content/docs/next/contributing/data-model.mdx +++ b/apps/fumadocs/content/docs/next/contributing/data-model.mdx @@ -8,7 +8,7 @@ This page owns the vocabulary of the schema. Every model name, column name and e ## Where the schema lives -Prisma reads a directory, not one file. Five files split the model by area. +Prisma reads a directory, not one file. Six files split the model by area. @@ -19,9 +19,10 @@ Prisma reads a directory, not one file. Five files split the model by area. + - + @@ -49,7 +50,7 @@ Two places supply the URL. `packages/db/prisma.config.ts` hands `process.env.DAT | Model and table | Key fields | Constraints and notes | | --- | --- | --- | -| `User`, table `user` | `id`, `name`, `email`, `emailVerified` (`false`), `twoFactorEnabled` (`false`), `image?`, `taxCountries String[]`, `onboardingCompletedAt?`, `createdAt`, `updatedAt` | `@@unique([email])`. Ten child relations, each with a cascade delete | +| `User`, table `user` | `id`, `name`, `email`, `emailVerified` (`false`), `twoFactorEnabled` (`false`), `image?`, `taxCountries String[]`, `onboardingCompletedAt?`, `role UserRole` (`MEMBER`), `createdAt`, `updatedAt` | `@@unique([email])`. Ten child relations, each with a cascade delete. Better Auth never writes `role` | | `Session`, table `session` | `expiresAt`, `token`, `ipAddress?`, `userAgent?`, `userId` | `@@unique([token])`, `@@index([userId])`, cascade | | `Account`, table `account` | `issuer`, `accountId`, `providerId`, `userId`, `accessToken?`, `refreshToken?`, `idToken?`, `accessTokenExpiresAt?`, `refreshTokenExpiresAt?`, `scope?`, `password?` | `@@unique([issuer, accountId], map: "account_issuer_accountId_uidx")`, `@@index([userId])`. The credential and OAuth link table, never a bank account. The password hash sits in `password` | | `Verification`, table `verification` | `identifier`, `value`, `expiresAt` | `@@index([identifier])`. No user column and no foreign key | @@ -156,9 +157,20 @@ The colour a subcategory shows is the stored colour of its parent, through `cust The `ordinal` column orders a conversation and `createdAt` cannot, because both rows of one turn can share a millisecond. The `parts` column holds the AI SDK message parts as the stream wrote them. So a stored answer replays with its lookup rows intact. +## Instance configuration + +`instance.prisma` holds what an operator sets in the setup screen, and the claim that names the operator. Neither model has a user relation, and neither one cascades. + +| Model and table | Key fields | Constraints and notes | +| --- | --- | --- | +| `InstanceSetting`, table `instance_setting` | `key` (`@id`), `sealed`, `updatedAt`, `updatedBy?` | One row per environment variable the screen owns. `sealed` is AES-256-GCM ciphertext under a key derived from `BETTER_AUTH_SECRET`, with the row `key` as associated data. `updatedBy` holds a `User.id` and carries no foreign key, so deleting the operator leaves the row | +| `InstanceSetup`, table `instance_setup` | `id` (`@id`, default `singleton`), `tokenHash?`, `claimedAt?`, `completedAt?`, `createdAt` | At most one row, for the whole setup lifecycle. `tokenHash` is the SHA-256 hex digest of the token the server printed, and the claim clears it. `claimedAt` marks the instance as owned; `completedAt` marks the wizard as finished, which is what stops the web app sending every visitor to `/setup` | + +The claim is one `updateMany` on `instance_setup`, matched on the hash and on `claimedAt: null`, inside the transaction that sets `User.role`. That conditional update is the lock. A second caller matches no row and is refused, so two callers cannot both become the operator. + ## Enums -PostgreSQL holds exactly three enum types. Every value below is verbatim, in declaration order. +PostgreSQL holds exactly five enum types. Every value below is verbatim, in declaration order. ### `BankConnectionStatus` @@ -207,6 +219,29 @@ The last column is `isInvestmentAccountType`, a TypeScript lookup in `packages/a | `USER` | The question of the reader | | `ASSISTANT` | The answer | +### `UserRole` + +`User.role` uses it, with the column default `MEMBER`. + +| Value | Meaning | +| --- | --- | +| `MEMBER` | The ordinary account. It reaches its own rows alone | +| `OPERATOR` | The account that claimed the instance. It opens the setup screen, and it reads no financial row of another user | + +One `user.update`, inside the claim transaction, is the only writer of `OPERATOR`. No endpoint writes the value back to `MEMBER`. + +### `SyncPhase` + +`SyncRun.phase` uses it, and the column has no default. + +| Value | Meaning | +| --- | --- | +| `IMPORTING` | The run is reading accounts and transactions from the providers | +| `CATEGORISING` | The import finished, and the run is resolving categories | +| `FINISHED` | The run ended. `SyncRun.finishedAt` holds the moment | + +One row per user, keyed by `userId`. A user therefore has one sync run at a time, and a new run replaces the last one. + ### The dropped enum `BudgetLineKind` was a fourth type with the values `REVENUE`, `INVESTMENT` and `OUTGOING`. The migration `20260904120000_derive_budget_line_kind` dropped the type together with the column `budget_line.kind`. The three names survive as a TypeScript union, and `budgetLineKindOf` derives the value from the group of a category. One group maps to each name: `income` to `REVENUE`, `investments` to `INVESTMENT` and `spending` to `OUTGOING`. diff --git a/apps/fumadocs/content/docs/next/quickstart.mdx b/apps/fumadocs/content/docs/next/quickstart.mdx index abff5f95..f31f0bb4 100644 --- a/apps/fumadocs/content/docs/next/quickstart.mdx +++ b/apps/fumadocs/content/docs/next/quickstart.mdx @@ -87,16 +87,7 @@ The console work takes four steps, and [Bank providers](/docs/self-hosting/bank- 3. Create a client application, and register `/callback/powens` as its redirect URI. 4. Activate the connectors of the banks you want. A domain with no active connector answers with an empty list. -Write the credentials into the same `.env` file: - -```dotenv -BANKING_PROVIDER=powens -POWENS_DOMAIN=acme-sandbox -POWENS_CLIENT_ID= -POWENS_CLIENT_SECRET= -``` - -A later change of these values needs `docker compose up -d`. Compose recreates the `server` container with the new environment, and no image rebuild happens. +Keep the domain, the client id and the client secret to hand. They go into the setup screen after the first start, and not into the `.env` file. @@ -139,7 +130,25 @@ Open `http://localhost:3001` in a browser. The screen reads `Welcome to Freenary With no email provider configured, Freenary signs you in at once and opens the onboarding wizard. With one configured, the screen moves to `Confirm your email` and asks for the 6-digit code it sent you. That code lives 10 minutes. Freenary signs you in after you enter it. -Freenary has no admin account and no owner role. The first account is an ordinary user. + + + + +### Claim the instance, then add the credentials + +The first account is an ordinary user until it claims the instance. Read the setup token from the server log: + +```bash +docker compose logs server | grep "setup token" +``` + +Open `http://localhost:3001/setup`, and enter the token. That account becomes the operator of this instance, and no second account can take the role. + +Now open the **Bank provider** card, pick `Powens`, and type the three values from the console. Press `Check and save`. Freenary calls your Powens domain with the credentials, and saves them only when the call succeeds. + +Press `Restart now`. The API server reads its configuration one time, at startup, so the credentials reach it after that restart. + +[The setup screen](/docs/self-hosting/setup) holds each card, each check and the restart in full. @@ -149,6 +158,7 @@ Freenary has no admin account and no owner role. The first account is an ordinar - You are signed in to your own instance. - Your account sits in the PostgreSQL database of that instance. +- You are the operator of the instance, so `/setup` opens for you and for nobody else. - **Settings** lists the banks of your tax residencies, because the credentials of the provider are set. - No bank is connected, so the Budget screen asks you to connect one. @@ -170,6 +180,11 @@ Freenary has no admin account and no owner role. The first account is an ordinar href="/docs/self-hosting" description="Public origins, real secrets, and the checks that prove them." /> + -Keep `BETTER_AUTH_SECRET` with the dump. That one value signs the session cookie, and it encrypts the `secret` and `backupCodes` columns of the `two_factor` table. It also signs the bank connection state that round-trips through the provider. A restore under a new secret signs every user out and breaks every two-factor enrolment. +Keep `BETTER_AUTH_SECRET` with the dump. That one value signs the session cookie, and it encrypts the `secret` and `backupCodes` columns of the `two_factor` table. It also seals every `instance_setting` row, and signs the bank connection state that round-trips through the provider. A restore under a new secret signs every user out and breaks every two-factor enrolment. It also turns off every provider the setup screen configured. diff --git a/apps/fumadocs/content/docs/next/self-hosting/bank-providers.mdx b/apps/fumadocs/content/docs/next/self-hosting/bank-providers.mdx index ab2bfa33..963f2af7 100644 --- a/apps/fumadocs/content/docs/next/self-hosting/bank-providers.mdx +++ b/apps/fumadocs/content/docs/next/self-hosting/bank-providers.mdx @@ -12,6 +12,8 @@ Freenary never talks to a bank itself. A bank provider holds that connection, an An unknown value fails the environment check at startup. A known value with no credentials turns bank linking off rather than half on. +An operator sets the provider and its credentials in [the setup screen](/docs/self-hosting/setup), not only in `.env`. The screen calls the provider with the credentials before it saves them. A value already set in the environment wins, and the screen marks it read-only. + **Choose Powens unless you have a reason not to.** It is the default, and it is the only adapter that imports accounts, balances and holdings. [Powens](#powens) below walks the console setup step by step. A sandbox domain costs nothing and needs no contract. ## The two adapters diff --git a/apps/fumadocs/content/docs/next/self-hosting/configuration.mdx b/apps/fumadocs/content/docs/next/self-hosting/configuration.mdx index c2758858..d15eb22c 100644 --- a/apps/fumadocs/content/docs/next/self-hosting/configuration.mdx +++ b/apps/fumadocs/content/docs/next/self-hosting/configuration.mdx @@ -28,6 +28,19 @@ The development stack reads the same file, and the file is optional there. `dock `migrate` gets one key, `DATABASE_URL`. `postgres` gets three: `POSTGRES_DB`, `POSTGRES_USER` and `POSTGRES_PASSWORD`. +## Where a value can be set + +Some of these variables also live in [the setup screen](/docs/self-hosting/setup). An operator types them into a form there, instead of into the file: + +| Integration | Variables the screen owns | +| --- | --- | +| Bank provider | `BANKING_PROVIDER`, `POWENS_DOMAIN`, `POWENS_CLIENT_ID`, `POWENS_CLIENT_SECRET`, `ENABLE_BANKING_APP_ID`, `ENABLE_BANKING_PRIVATE_KEY` | +| Email delivery | `EMAIL_PROVIDER`, `EMAIL_FROM`, `RESEND_API_KEY`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE`, `SMTP_USER`, `SMTP_PASSWORD` | + +**A value in the environment wins.** The server reads a variable from the environment first, and falls back to the row the screen wrote. A line in `.env` therefore makes the matching field read-only, and the screen names the variable the environment owns. + +Every other variable on this page comes from the environment alone. + ## How the server checks its environment {/* env-sync:start count */} diff --git a/apps/fumadocs/content/docs/next/self-hosting/email.mdx b/apps/fumadocs/content/docs/next/self-hosting/email.mdx index 4a211fb3..34998433 100644 --- a/apps/fumadocs/content/docs/next/self-hosting/email.mdx +++ b/apps/fumadocs/content/docs/next/self-hosting/email.mdx @@ -26,6 +26,8 @@ Freenary checks `EMAIL_FROM` before `SMTP_HOST`. A stack that sets neither there [Configuration](/docs/self-hosting/configuration) defines every variable named here. +An operator sets these values in [the setup screen](/docs/self-hosting/setup), and not only in the `.env` file. The screen opens the connection and signs in before it saves. A wrong password is therefore refused at the form, and not at the first one-time code. A value already set in the environment wins, and the screen marks it read-only. + ## What stops with no adapter These sign-in paths still work: email and password, passkeys, and two-factor with an authenticator app. diff --git a/apps/fumadocs/content/docs/next/self-hosting/index.mdx b/apps/fumadocs/content/docs/next/self-hosting/index.mdx index 155d313c..484a6d25 100644 --- a/apps/fumadocs/content/docs/next/self-hosting/index.mdx +++ b/apps/fumadocs/content/docs/next/self-hosting/index.mdx @@ -108,7 +108,7 @@ Put your own hostnames in the last two lines. The API server refuses a secret un ### Set up the bank provider -Do this before the first start, so the instance serves working screens from the beginning. +Do the console work before the first start, so the instance serves working screens from the beginning. Create a sandbox domain and a client application in the [Powens console](https://console.powens.com). Register this redirect URI on the client: @@ -120,16 +120,9 @@ The value is your `CORS_ORIGIN` and the path `/callback/powens`. Powens matches Activate the connectors of the banks you want to offer. A domain with no active connector answers with an empty bank list. -Then add the credentials of the client to `.env`: +Keep the domain, the client id and the client secret to hand. The credentials go into [the setup screen](/docs/self-hosting/setup) after the first start, and not into the `.env` file. -```dotenv -BANKING_PROVIDER=powens -POWENS_DOMAIN=acme-sandbox -POWENS_CLIENT_ID= -POWENS_CLIENT_SECRET= -``` - -[Bank providers](/docs/self-hosting/bank-providers) holds each console step, the sandbox limits, and the Enable Banking alternative. An empty `POWENS_` variable turns bank linking off, and no message says so. +[Bank providers](/docs/self-hosting/bank-providers) holds each console step, the sandbox limits, and the Enable Banking alternative. An instance with no credentials turns bank linking off, and no message says so. @@ -187,12 +180,28 @@ docker compose logs -f server ### Create the first account -Open the web app origin in a browser, then sign up with an email address and a password. Freenary has no admin account and no owner role, so the first account is an ordinary user. [First steps](/docs/guides/first-steps) covers what that user does next. +Open the web app origin in a browser, then sign up with an email address and a password. [First steps](/docs/guides/first-steps) covers what that user does next. The wizard that opens proves the bank provider. It asks for a tax residency, then offers a bank step with the banks your domain activated. A missing bank step means Freenary read no credentials, and Settings then reads `Bank linking is unavailable`. + + +### Claim the instance + +The account you made is an ordinary user until it claims the instance. Read the setup token from the server log: + +```bash +docker compose logs server | grep "setup token" +``` + +Open `/setup` on the web app origin, and enter the token. That account becomes the operator, and no second account can take the role. An unclaimed instance on a public origin is claimable by anybody who finds it. Do this in the same sitting. + +The setup screen is where the bank and email credentials go. [The setup screen](/docs/self-hosting/setup) walks each card and the restart that applies it. + + + @@ -203,6 +212,12 @@ The first sign-up is the moment to decide about email verification. With no emai + + The schema default of `BETTER_AUTH_SECRET` is the literal `dev_secret_change_me_at_least_32chars`. It is 37 characters long, so it passes validation, and no production check refuses it. The Docker stack never reaches that default. A process started outside the stack does, and anyone can then forge a session cookie against it. @@ -118,7 +133,9 @@ Treat the database and every dump of it as the financial data itself. Encrypt th Freenary enables no account deletion. No screen and no endpoint removes a user. An operator who must erase one person does it in the database. A user can delete a bank connection, and that delete removes its accounts, transactions and holdings through the database cascade. -There are also no roles and no admin surface. Every signed-in account has the same rights, and each one reaches its own rows alone. +One role exists, and it is the operator. It opens the setup screen and changes the provider settings of the instance. It reads no financial row of another user, and every signed-in account otherwise reaches its own rows alone. + +The `instance_setting` table holds the provider credentials the operator typed. Each row is sealed with AES-256-GCM, under a key made from `BETTER_AUTH_SECRET`. A reader of the table alone gets ciphertext. ## The checklist @@ -132,7 +149,8 @@ Work through each line before real users arrive. - [ ] The `postgres` service publishes nothing, or it publishes to `127.0.0.1` alone. - [ ] `TRUSTED_PROXIES` lists every proxy in front of the API server. - [ ] `AUTH_COOKIE_DOMAIN` names the shared parent domain, for two subdomains. -- [ ] `EMAIL_PROVIDER` is `resend` or `smtp`, so password reset works. +- [ ] An account claimed the instance, so `/setup` refuses everybody else. +- [ ] The email provider is `resend` or `smtp`, so password reset works. - [ ] `docker compose ps -a` shows `migrate` as `Exited (0)` and the rest as `healthy`. - [ ] You know which endpoint `AI_BASE_URL` names, or you left it unset. - [ ] A dump runs on a schedule, and you restored one to check it. diff --git a/apps/fumadocs/content/docs/next/self-hosting/setup.mdx b/apps/fumadocs/content/docs/next/self-hosting/setup.mdx new file mode 100644 index 00000000..f686654b --- /dev/null +++ b/apps/fumadocs/content/docs/next/self-hosting/setup.mdx @@ -0,0 +1,154 @@ +--- +title: The setup screen +description: Claim a new instance, then set the bank and email providers from the app instead of the env file. +icon: Wrench +--- + +A new instance serves a setup screen at `/setup`. It is a wizard with one step per integration. It holds the provider settings an operator changes after the install. + +**An instance that has not finished the wizard sends every signed-in visitor there.** The web app reads the setup state before it renders any protected screen. A fresh deployment therefore opens on the wizard, and not on an empty home screen. The redirect stops when an operator presses `Finish setup`. + +The screen checks each credential against the real service before it saves anything. A credential the service refuses is never written. + +## What the screen owns, and what stays in the file + +The `.env` file keeps the values the instance needs before it can serve the screen at all: + +| Value | Why the file keeps it | +| --- | --- | +| `POSTGRES_PASSWORD` | The setup screen writes to the database, so the database opens first | +| `BETTER_AUTH_SECRET` | It seals what the screen stores, and it signs the session that opens the screen | +| `BETTER_AUTH_URL`, `CORS_ORIGIN` | The browser reaches the screen over these two origins | +| `FREENARY_VERSION` | Compose reads it before any container starts | + +Everything the setup screen holds stays out of the file. [Configuration](/docs/self-hosting/configuration) lists every variable, and names the ones the screen owns. + +## Claim the instance + +The first account that opens `/setup` is not the operator yet. An instance on a public origin is open to anybody who finds it. The claim therefore needs a token the server prints to its own log. + + + + + +### Read the setup token + +```bash +docker compose logs server | grep "setup token" +``` + +The line names the origin and the token: + +```text +This instance is unclaimed. Open https://app.example.com/setup and enter this setup token: s6kSPDviSE5J_r3wgUos1a06mq9Z1qmt +``` + +The server writes a new token at each start while the instance is unclaimed. Only the hash of it reaches the database. A restart therefore makes the last token useless. Read the log again after a restart. + + + + + +### Sign up, then claim + +Open the web app and create an account in the usual way. Then open `/setup`, and enter the token. + +That account becomes the operator. The server prints no token after the claim, and no second account can take the role. + + + + + +## Walk the steps + +The wizard runs `Claim`, then one step per integration, then `Done`. Each integration step holds a provider list, the fields of the provider you pick, and three buttons. + +**Every integration step is optional.** `Skip` moves to the next step and writes nothing. An operator who wants email alone therefore passes the bank step, and comes back later. `Back` returns to the last step, and a skipped step changes no row. + + + + + +### Pick the provider + +**Bank provider** offers Powens and Enable Banking. **Email delivery** offers Resend and SMTP. Both offer `Not connected`, which clears the credentials and turns the integration off. + + + + + +### Fill the fields, then press `Check and save` + +Freenary calls the service with the credentials you typed: + +| Integration | The call it makes | +| -------------- | ----------------------------------------------------------- | +| Powens | Creates a throwaway user on your domain, then deletes it | +| Enable Banking | Signs a token with your PEM key, then reads the application | +| Resend | Reads the domain list of the account | +| SMTP | Opens the connection and signs in, and sends no message | + +A failed call names what happened. The service was unreachable, the service refused the credentials, or a value has the wrong shape. Nothing is saved until the call succeeds. + + + + + +### Restart the server, then finish + +The API server reads its configuration one time, at startup. A saved credential reaches the running process only after a restart. The last step offers `Restart now`, and the screen waits for the server to come back on its own. + +```bash +docker compose restart server +``` + +The command above does the same thing from a shell. Press `Finish setup` to end the wizard. The instance then stops sending visitors to `/setup`, and the screen stays open for a later change. + + + + + +## A value in the environment wins + +A variable set in the environment always beats the same value in the database. The card marks such a field as owned by the environment. It also refuses to save the integration while any of its variables come from the file. + +This keeps two habits working. An operator who holds secrets in Kubernetes, in Vault or in the file keeps that setup. The screen never fights it. An operator who wants the screen to own a value removes the line from `.env`, then runs `docker compose up -d`. + +## Where the values live + +Each value sits in one row of the `instance_setting` table, sealed with AES-256-GCM. The key comes from `BETTER_AUTH_SECRET` and the row name, so a row moved to another name no longer opens. + +Two results follow: + +- A database dump holds the credentials. It needs the same care as the `.env` file. Read [Backup and restore](/docs/self-hosting/backup-and-restore). +- A new `BETTER_AUTH_SECRET` makes every stored value unreadable. The server logs one warning for each row it cannot open. The integration then turns off until the operator types the credential again. + +The screen never sends a stored secret back to the browser. A secret field shows that a value exists, and an empty box keeps it. + + + + + + + + + + + + diff --git a/apps/server/package.json b/apps/server/package.json index e78cdd4f..90ece9b8 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -16,6 +16,7 @@ "@freenary/auth": "workspace:*", "@freenary/db": "workspace:*", "@freenary/env": "workspace:*", + "@freenary/instance-config": "workspace:*", "@orpc/openapi": "catalog:", "@orpc/server": "catalog:", "@orpc/zod": "catalog:", diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 699f12e1..b6f13c7b 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -4,11 +4,13 @@ import { createContext } from "@freenary/api/context"; import { appRouter } from "@freenary/api/routers/index"; import { auth } from "@freenary/auth"; import { env } from "@freenary/env/server"; +import { issueSetupToken } from "@freenary/instance-config"; import { OpenAPIHandler } from "@orpc/openapi/fetch"; import { OpenAPIReferencePlugin } from "@orpc/openapi/plugins"; import { ORPCError, onError } from "@orpc/server"; import { RPCHandler } from "@orpc/server/fetch"; import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4"; +import { Effect, Option } from "effect"; import { Elysia } from "elysia"; import { initLogger } from "evlog"; import { createAuthMiddleware } from "evlog/better-auth"; @@ -142,3 +144,11 @@ new Elysia() .listen(env.PORT, () => { console.log(`Server is running on http://localhost:${env.PORT}`); }); + +const setupToken = await Effect.runPromise(issueSetupToken()); + +if (Option.isSome(setupToken)) { + console.log( + `This instance is unclaimed. Open ${env.CORS_ORIGIN}/setup and enter this setup token: ${setupToken.value}` + ); +} diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index c26b6a7c..416308c1 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -714,12 +714,12 @@ "settings_2fa_confirm": "Turn on two-factor authentication", "settings_2fa_continue": "Continue", "settings_2fa_description": "A code from your authenticator app, on top of your password.", + "settings_2fa_dialog_title_enable": "Enable two-factor authentication", + "settings_2fa_dialog_title_regenerate": "New recovery codes", "settings_2fa_disable_confirm": "Disable", "settings_2fa_disable_description": "Your password alone will sign you in again. Confirm it to continue.", "settings_2fa_disable_title": "Disable two-factor authentication?", "settings_2fa_disabled_toast": "Two-factor authentication is off", - "settings_2fa_dialog_title_enable": "Enable two-factor authentication", - "settings_2fa_dialog_title_regenerate": "New recovery codes", "settings_2fa_enabled_toast": "Two-factor authentication is on", "settings_2fa_error_code_format": [ { @@ -1023,6 +1023,73 @@ } } ], + "setup_back": "Back", + "setup_claim_description": "The server printed a setup token when it started. Enter it to become this instance's operator.", + "setup_claim_error": "That token was refused. Read the server log again and retry.", + "setup_claim_submit": "Claim", + "setup_claim_success": "You are now the operator of this instance.", + "setup_claim_title": "Claim this instance", + "setup_claim_token_hint": "Read it from the server log with docker compose logs server.", + "setup_claim_token_label": "Setup token", + "setup_completed_toast": "The instance is set up.", + "setup_description": "Choose the services this instance uses and enter their credentials. Freenary checks each one before it saves.", + "setup_error_environment_owned": "{keys} come from the environment. Remove them from the .env file to set them here.", + "setup_error_invalid": "The values were refused: {detail}", + "setup_error_missing_fields": "Fill in {keys}.", + "setup_error_probe_invalid": "A value has the wrong shape: {detail}", + "setup_error_probe_rejected": "The service refused the credentials ({status}): {detail}", + "setup_error_probe_unreachable": "The service could not be reached: {detail}", + "setup_fault_badge": "Configuration problem", + "setup_fault_title": "The stored configuration did not load: {detail}", + "setup_field_email_from": "Sender address", + "setup_field_enable_banking_app_id": "Application ID", + "setup_field_enable_banking_private_key": "Private key (PEM)", + "setup_field_optional": "(optional)", + "setup_field_powens_client_id": "Client ID", + "setup_field_powens_client_secret": "Client secret", + "setup_field_powens_domain": "Powens domain", + "setup_field_resend_api_key": "API key", + "setup_field_smtp_host": "Host", + "setup_field_smtp_password": "Password", + "setup_field_smtp_port": "Port", + "setup_field_smtp_secure": "Implicit TLS", + "setup_field_smtp_user": "User", + "setup_finish_action": "Finish setup", + "setup_finish_description": "This is what the instance runs with. You can change any of it later from this screen.", + "setup_finish_title": "Ready to go", + "setup_integration_banking_description": "The service Freenary uses to connect a bank account.", + "setup_integration_banking_title": "Bank provider", + "setup_integration_email_description": "Needed for one-time codes, address confirmation and password reset.", + "setup_integration_email_title": "Email delivery", + "setup_loading": "Loading the setup screen", + "setup_locked_by_environment": "The environment sets this integration, so it cannot be changed here. Remove the lines from the .env file to take it over.", + "setup_not_operator_description": "Another account operates this instance. Ask its operator to change the configuration.", + "setup_not_operator_title": "Already claimed", + "setup_progress_label": "Setup progress", + "setup_restart_action": "Restart now", + "setup_restart_description": "The server reads its configuration once at startup. Restart it to use what you saved.", + "setup_restart_pending": "Restarting. This page reconnects on its own.", + "setup_restart_title": "Restart to apply", + "setup_save": "Check and save", + "setup_saved": "Saved. Restart to apply it.", + "setup_saving": "Checking…", + "setup_secret_unchanged_hint": "Saved. Leave empty to keep it.", + "setup_skip": "Skip", + "setup_source_environment": "{key} comes from the environment and cannot be changed here.", + "setup_state_configured": "Configured", + "setup_state_disabled": "Off", + "setup_state_incomplete": "Incomplete", + "setup_step_bank": "Bank", + "setup_step_claim": "Claim", + "setup_step_email": "Email", + "setup_step_finish": "Done", + "setup_title": "Set up this instance", + "setup_variant_disabled": "Not connected", + "setup_variant_enable_banking": "Enable Banking", + "setup_variant_label": "Provider", + "setup_variant_powens": "Powens", + "setup_variant_resend": "Resend", + "setup_variant_smtp": "SMTP", "shell_brand_name": "Freenary", "shell_dashboard_welcome": "Welcome back, {name}", "shell_nav_planned_badge": "Planned", diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index bdc439c5..209bfff8 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -723,12 +723,12 @@ "settings_2fa_confirm": "Activer l’authentification à deux facteurs", "settings_2fa_continue": "Continuer", "settings_2fa_description": "Un code de votre application d’authentification, en plus de votre mot de passe.", + "settings_2fa_dialog_title_enable": "Activer l’authentification à deux facteurs", + "settings_2fa_dialog_title_regenerate": "Nouveaux codes de secours", "settings_2fa_disable_confirm": "Désactiver", "settings_2fa_disable_description": "Votre mot de passe seul suffira de nouveau à vous connecter. Confirmez-le pour continuer.", "settings_2fa_disable_title": "Désactiver l’authentification à deux facteurs ?", "settings_2fa_disabled_toast": "Authentification à deux facteurs désactivée", - "settings_2fa_dialog_title_enable": "Activer l’authentification à deux facteurs", - "settings_2fa_dialog_title_regenerate": "Nouveaux codes de secours", "settings_2fa_enabled_toast": "Authentification à deux facteurs activée", "settings_2fa_error_code_format": [ { @@ -1032,6 +1032,73 @@ } } ], + "setup_back": "Retour", + "setup_claim_description": "Le serveur a affiché un jeton d'installation à son démarrage. Saisissez-le pour devenir l'opérateur de cette instance.", + "setup_claim_error": "Ce jeton a été refusé. Relisez le journal du serveur et réessayez.", + "setup_claim_submit": "Revendiquer", + "setup_claim_success": "Vous êtes désormais l'opérateur de cette instance.", + "setup_claim_title": "Revendiquer cette instance", + "setup_claim_token_hint": "Lisez-le dans le journal du serveur avec docker compose logs server.", + "setup_claim_token_label": "Jeton d'installation", + "setup_completed_toast": "L'instance est configurée.", + "setup_description": "Choisissez les services utilisés par cette instance et saisissez leurs identifiants. Freenary vérifie chacun d'eux avant d'enregistrer.", + "setup_error_environment_owned": "{keys} proviennent de l'environnement. Retirez-les du fichier .env pour les définir ici.", + "setup_error_invalid": "Les valeurs ont été refusées : {detail}", + "setup_error_missing_fields": "Renseignez {keys}.", + "setup_error_probe_invalid": "Une valeur n'a pas le bon format : {detail}", + "setup_error_probe_rejected": "Le service a refusé les identifiants ({status}) : {detail}", + "setup_error_probe_unreachable": "Le service est injoignable : {detail}", + "setup_fault_badge": "Problème de configuration", + "setup_fault_title": "La configuration enregistrée n'a pas pu être chargée : {detail}", + "setup_field_email_from": "Adresse d'expédition", + "setup_field_enable_banking_app_id": "Identifiant d'application", + "setup_field_enable_banking_private_key": "Clé privée (PEM)", + "setup_field_optional": "(facultatif)", + "setup_field_powens_client_id": "Identifiant client", + "setup_field_powens_client_secret": "Secret client", + "setup_field_powens_domain": "Domaine Powens", + "setup_field_resend_api_key": "Clé d'API", + "setup_field_smtp_host": "Hôte", + "setup_field_smtp_password": "Mot de passe", + "setup_field_smtp_port": "Port", + "setup_field_smtp_secure": "TLS implicite", + "setup_field_smtp_user": "Utilisateur", + "setup_finish_action": "Terminer la configuration", + "setup_finish_description": "Voici ce avec quoi l'instance fonctionne. Vous pouvez tout modifier plus tard depuis cet écran.", + "setup_finish_title": "Tout est prêt", + "setup_integration_banking_description": "Le service que Freenary utilise pour connecter un compte bancaire.", + "setup_integration_banking_title": "Fournisseur bancaire", + "setup_integration_email_description": "Nécessaire pour les codes à usage unique, la confirmation d'adresse et la réinitialisation du mot de passe.", + "setup_integration_email_title": "Envoi des courriels", + "setup_loading": "Chargement de l'écran de configuration", + "setup_locked_by_environment": "L'environnement définit cette intégration, elle ne peut donc pas être modifiée ici. Retirez les lignes du fichier .env pour la reprendre.", + "setup_not_operator_description": "Un autre compte administre cette instance. Demandez à son opérateur de modifier la configuration.", + "setup_not_operator_title": "Déjà revendiquée", + "setup_progress_label": "Progression de la configuration", + "setup_restart_action": "Redémarrer maintenant", + "setup_restart_description": "Le serveur lit sa configuration une seule fois au démarrage. Redémarrez-le pour utiliser ce que vous avez enregistré.", + "setup_restart_pending": "Redémarrage en cours. Cette page se reconnecte d'elle-même.", + "setup_restart_title": "Redémarrer pour appliquer", + "setup_save": "Vérifier et enregistrer", + "setup_saved": "Enregistré. Redémarrez pour appliquer.", + "setup_saving": "Vérification…", + "setup_secret_unchanged_hint": "Enregistré. Laissez vide pour le conserver.", + "setup_skip": "Passer", + "setup_source_environment": "{key} provient de l'environnement et ne peut pas être modifié ici.", + "setup_state_configured": "Configuré", + "setup_state_disabled": "Désactivé", + "setup_state_incomplete": "Incomplet", + "setup_step_bank": "Banque", + "setup_step_claim": "Revendiquer", + "setup_step_email": "Courriel", + "setup_step_finish": "Fin", + "setup_title": "Configurer cette instance", + "setup_variant_disabled": "Non connecté", + "setup_variant_enable_banking": "Enable Banking", + "setup_variant_label": "Fournisseur", + "setup_variant_powens": "Powens", + "setup_variant_resend": "Resend", + "setup_variant_smtp": "SMTP", "shell_brand_name": "Freenary", "shell_dashboard_welcome": "Ravi de vous revoir, {name}", "shell_nav_planned_badge": "Prévu", diff --git a/apps/web/src/app/api/get-instance.ts b/apps/web/src/app/api/get-instance.ts new file mode 100644 index 00000000..f3604f65 --- /dev/null +++ b/apps/web/src/app/api/get-instance.ts @@ -0,0 +1,34 @@ +import { createServerFn } from "@tanstack/react-start"; +import { getRequestHeader } from "@tanstack/react-start/server"; +import { Data, Effect } from "effect"; + +import { client } from "@/shared/api"; + +export type InstanceSetup = + | { kind: "needs-setup" } + | { kind: "ready" } + | { kind: "unknown" }; + +export const UNKNOWN_INSTANCE: InstanceSetup = { kind: "unknown" }; + +class InstanceRequestFailed extends Data.TaggedError("InstanceRequestFailed")<{ + readonly cause: unknown; +}> {} + +const resolveInstance = (cookie: string | undefined) => + Effect.tryPromise({ + catch: (cause) => new InstanceRequestFailed({ cause }), + try: () => client.instance.status(undefined, { context: { cookie } }), + }).pipe( + Effect.map((answer): InstanceSetup => + answer.completed ? { kind: "ready" } : { kind: "needs-setup" } + ), + Effect.catchTag("InstanceRequestFailed", () => + Effect.succeed(UNKNOWN_INSTANCE) + ) + ); + +export const getInstanceSetup = createServerFn({ method: "GET" }).handler( + (): Promise => + Effect.runPromise(resolveInstance(getRequestHeader("cookie"))) +); diff --git a/apps/web/src/app/routes/__root.tsx b/apps/web/src/app/routes/__root.tsx index 7d3901af..4a53b8ed 100644 --- a/apps/web/src/app/routes/__root.tsx +++ b/apps/web/src/app/routes/__root.tsx @@ -22,6 +22,7 @@ import { publicServerUrlScript } from "@/shared/config"; import { ffIcons } from "@/shared/lib/ff-icons"; import { isServer } from "@/shared/lib/is-server"; +import { UNKNOWN_INSTANCE, getInstanceSetup } from "../api/get-instance"; import { UNKNOWN_VIEWER, getViewer } from "../api/get-viewer"; import appCss from "../styles/index.css?url"; @@ -94,6 +95,7 @@ export const Route = createRootRouteWithContext()({ }, beforeLoad: async () => ({ + instance: isServer ? await getInstanceSetup() : UNKNOWN_INSTANCE, viewer: isServer ? await getViewer() : UNKNOWN_VIEWER, }), diff --git a/apps/web/src/app/routes/_auth/route.tsx b/apps/web/src/app/routes/_auth/route.tsx index fa09c5e0..ef6603af 100644 --- a/apps/web/src/app/routes/_auth/route.tsx +++ b/apps/web/src/app/routes/_auth/route.tsx @@ -3,11 +3,15 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; import { AuthLayout } from "../../shell/auth-layout"; export const Route = createFileRoute("/_auth")({ - beforeLoad: ({ context: { viewer } }) => { + beforeLoad: ({ context: { instance, viewer } }) => { if (viewer.kind === "guest") { throw redirect({ to: "/login" }); } + if (instance.kind === "needs-setup") { + throw redirect({ to: "/setup" }); + } + if (viewer.kind === "member" && !viewer.onboarded) { throw redirect({ to: "/onboarding" }); } diff --git a/apps/web/src/app/routes/login.tsx b/apps/web/src/app/routes/login.tsx index 02bcc365..a9293df2 100644 --- a/apps/web/src/app/routes/login.tsx +++ b/apps/web/src/app/routes/login.tsx @@ -6,10 +6,16 @@ import { LoginPage } from "@/pages/login"; const loginSearchSchema = z.object({ error: z.string().optional() }); export const Route = createFileRoute("/login")({ - beforeLoad: ({ context: { viewer } }) => { - if (viewer.kind === "member") { - throw redirect({ to: viewer.onboarded ? "/" : "/onboarding" }); + beforeLoad: ({ context: { instance, viewer } }) => { + if (viewer.kind !== "member") { + return; } + + if (instance.kind === "needs-setup") { + throw redirect({ to: "/setup" }); + } + + throw redirect({ to: viewer.onboarded ? "/" : "/onboarding" }); }, component: LoginPage, validateSearch: loginSearchSchema, diff --git a/apps/web/src/app/routes/onboarding.tsx b/apps/web/src/app/routes/onboarding.tsx index 34c532bc..68ebc07d 100644 --- a/apps/web/src/app/routes/onboarding.tsx +++ b/apps/web/src/app/routes/onboarding.tsx @@ -3,11 +3,15 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; import { OnboardingPage } from "@/pages/onboarding"; export const Route = createFileRoute("/onboarding")({ - beforeLoad: ({ context: { viewer } }) => { + beforeLoad: ({ context: { instance, viewer } }) => { if (viewer.kind === "guest") { throw redirect({ to: "/login" }); } + if (instance.kind === "needs-setup") { + throw redirect({ to: "/setup" }); + } + if (viewer.kind === "member" && viewer.onboarded) { throw redirect({ to: "/" }); } diff --git a/apps/web/src/app/routes/setup.tsx b/apps/web/src/app/routes/setup.tsx new file mode 100644 index 00000000..09ef5e0c --- /dev/null +++ b/apps/web/src/app/routes/setup.tsx @@ -0,0 +1,12 @@ +import { createFileRoute, redirect } from "@tanstack/react-router"; + +import { SetupPage } from "@/pages/setup"; + +export const Route = createFileRoute("/setup")({ + beforeLoad: ({ context: { viewer } }) => { + if (viewer.kind === "guest") { + throw redirect({ to: "/login" }); + } + }, + component: SetupPage, +}); diff --git a/apps/web/src/pages/onboarding/ui/bank-connection-step.tsx b/apps/web/src/pages/onboarding/ui/bank-connection-step.tsx index ff350f62..3851fd92 100644 --- a/apps/web/src/pages/onboarding/ui/bank-connection-step.tsx +++ b/apps/web/src/pages/onboarding/ui/bank-connection-step.tsx @@ -4,8 +4,7 @@ import { useIcon } from "@freenary/ui/lib/icon-context"; import { BankConnectionPanel } from "@/features/bank-connection"; import type { BankInstitution } from "@/features/bank-connection"; import { m } from "@/paraglide/messages.js"; - -import { OnboardingStepHeader } from "./onboarding-step-header"; +import { WizardStepHeader } from "@/shared/ui/wizard-step-header"; interface BankConnectionStepProps { banks: BankInstitution[]; @@ -32,7 +31,7 @@ export const BankConnectionStep = ({ return (
- diff --git a/apps/web/src/pages/onboarding/ui/country-selection-step.tsx b/apps/web/src/pages/onboarding/ui/country-selection-step.tsx index 425532e3..fe4e708e 100644 --- a/apps/web/src/pages/onboarding/ui/country-selection-step.tsx +++ b/apps/web/src/pages/onboarding/ui/country-selection-step.tsx @@ -20,8 +20,7 @@ import { isFullySupportedCountry, } from "@/shared/lib/countries"; import { remixIcon } from "@/shared/lib/remix-icon"; - -import { OnboardingStepHeader } from "./onboarding-step-header"; +import { WizardStepHeader } from "@/shared/ui/wizard-step-header"; interface CountrySelectionStepProps { isCompleting: boolean; @@ -58,7 +57,7 @@ export const CountrySelectionStep = ({ return (
- diff --git a/apps/web/src/pages/onboarding/ui/onboarding-wizard.tsx b/apps/web/src/pages/onboarding/ui/onboarding-wizard.tsx index d094cb27..aebad844 100644 --- a/apps/web/src/pages/onboarding/ui/onboarding-wizard.tsx +++ b/apps/web/src/pages/onboarding/ui/onboarding-wizard.tsx @@ -1,25 +1,14 @@ import { Button } from "@freenary/ui/components/button"; -import { spring } from "@freenary/ui/lib/springs"; -import { SURFACE_BG } from "@freenary/ui/lib/surface-classes"; -import { SurfaceProvider } from "@freenary/ui/lib/surface-context"; -import { cn } from "@freenary/ui/lib/utils"; -import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import type { BankInstitution } from "@/features/bank-connection"; import { m } from "@/paraglide/messages.js"; -import { LocaleSwitcher } from "@/shared/ui/locale-switcher"; -import { ThemeSwitcher } from "@/shared/ui/theme-switcher"; +import { WizardShell } from "@/shared/ui/wizard-shell"; +import { WizardStepper } from "@/shared/ui/wizard-stepper"; import { BankConnectionStep } from "./bank-connection-step"; import { CountrySelectionStep } from "./country-selection-step"; -import { OnboardingStepper } from "./onboarding-stepper"; import { OnboardingWizardSkeleton } from "./onboarding-wizard-skeleton"; -interface StepMotion { - direction: 1 | -1; - shift: number; -} - interface OnboardingWizardProps { banks: BankInstitution[]; connectedCount: number; @@ -48,26 +37,6 @@ const STEP_LABEL_FNS_WITHOUT_BANKING = [ m.onboarding_step_country, ] as const satisfies readonly (() => string)[]; -const STEP_SHIFT_PX = 16; -const NO_STEP_SHIFT_PX = 0; -const STEP_CROSSFADE_MASK_BLUR = "blur(4px)"; -const NO_BLUR = "blur(0px)"; - -const stepVariants = { - center: { filter: NO_BLUR, opacity: 1, transition: spring.slow, x: 0 }, - enter: ({ direction, shift }: StepMotion) => ({ - filter: shift ? STEP_CROSSFADE_MASK_BLUR : NO_BLUR, - opacity: 0, - x: direction * shift, - }), - exit: ({ direction, shift }: StepMotion) => ({ - filter: shift ? STEP_CROSSFADE_MASK_BLUR : NO_BLUR, - opacity: 0, - transition: spring.slow.exit, - x: -direction * shift, - }), -}; - export const OnboardingWizard = ({ banks, connectedCount, @@ -85,94 +54,43 @@ export const OnboardingWizard = ({ step, taxCountries, unavailableCountries, -}: OnboardingWizardProps) => { - const prefersReducedMotion = useReducedMotion(); - const stepMotion: StepMotion = { - direction, - shift: prefersReducedMotion ? NO_STEP_SHIFT_PX : STEP_SHIFT_PX, - }; - - return ( - -
-
- - - -
- -
-
- - {isPending ? ( - - - - ) : ( - - - - - {step === 0 ? ( - - ) : ( - - )} - - - - )} - -
-
-
-
- ); -}; +}: OnboardingWizardProps) => ( + } + stepKey={String(step)} + stepper={ + + } + toolbar={ + + } + > + {step === 0 ? ( + + ) : ( + + )} + +); diff --git a/apps/web/src/pages/setup/index.ts b/apps/web/src/pages/setup/index.ts new file mode 100644 index 00000000..775e3b7e --- /dev/null +++ b/apps/web/src/pages/setup/index.ts @@ -0,0 +1 @@ +export { SetupPage } from "./ui/setup-page"; diff --git a/apps/web/src/pages/setup/model/labels.ts b/apps/web/src/pages/setup/model/labels.ts new file mode 100644 index 00000000..8cc90687 --- /dev/null +++ b/apps/web/src/pages/setup/model/labels.ts @@ -0,0 +1,77 @@ +import type { BadgeColor } from "@freenary/ui/components/badge"; + +import { m } from "@/paraglide/messages.js"; + +type Label = () => string; + +const FIELD_LABELS = { + EMAIL_FROM: m.setup_field_email_from, + ENABLE_BANKING_APP_ID: m.setup_field_enable_banking_app_id, + ENABLE_BANKING_PRIVATE_KEY: m.setup_field_enable_banking_private_key, + POWENS_CLIENT_ID: m.setup_field_powens_client_id, + POWENS_CLIENT_SECRET: m.setup_field_powens_client_secret, + POWENS_DOMAIN: m.setup_field_powens_domain, + RESEND_API_KEY: m.setup_field_resend_api_key, + SMTP_HOST: m.setup_field_smtp_host, + SMTP_PASSWORD: m.setup_field_smtp_password, + SMTP_PORT: m.setup_field_smtp_port, + SMTP_SECURE: m.setup_field_smtp_secure, + SMTP_USER: m.setup_field_smtp_user, +} satisfies Record; + +const INTEGRATION_TITLES = { + banking: m.setup_integration_banking_title, + email: m.setup_integration_email_title, +} satisfies Record; + +const INTEGRATION_DESCRIPTIONS = { + banking: m.setup_integration_banking_description, + email: m.setup_integration_email_description, +} satisfies Record; + +const STATE_LABELS = { + configured: m.setup_state_configured, + disabled: m.setup_state_disabled, + incomplete: m.setup_state_incomplete, +} satisfies Record; + +const STEP_LABELS = { + banking: m.setup_step_bank, + claim: m.setup_step_claim, + email: m.setup_step_email, + finish: m.setup_step_finish, +} satisfies Record; + +const VARIANT_LABELS = { + disabled: m.setup_variant_disabled, + "enable-banking": m.setup_variant_enable_banking, + powens: m.setup_variant_powens, + resend: m.setup_variant_resend, + smtp: m.setup_variant_smtp, +} satisfies Record; + +const STATE_COLOURS: Record = { + configured: "green", + disabled: "gray", + incomplete: "amber", +}; + +const read = (table: Record, key: string): string => + table[key]?.() ?? key; + +export const fieldLabel = (key: string): string => read(FIELD_LABELS, key); + +export const integrationTitle = (id: string): string => + read(INTEGRATION_TITLES, id); + +export const integrationDescription = (id: string): string => + read(INTEGRATION_DESCRIPTIONS, id); + +export const variantLabel = (id: string): string => read(VARIANT_LABELS, id); + +export const stateLabel = (id: string): string => read(STATE_LABELS, id); + +export const stepLabel = (id: string): string => read(STEP_LABELS, id); + +export const stateColour = (id: string): BadgeColor => + STATE_COLOURS[id] ?? "gray"; diff --git a/apps/web/src/pages/setup/model/use-setup.ts b/apps/web/src/pages/setup/model/use-setup.ts new file mode 100644 index 00000000..eeae0037 --- /dev/null +++ b/apps/web/src/pages/setup/model/use-setup.ts @@ -0,0 +1,151 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import { useState } from "react"; +import { toast } from "sonner"; + +import { m } from "@/paraglide/messages.js"; +import { orpc } from "@/shared/api"; +import { authClient } from "@/shared/auth"; + +const RECONNECT_POLL_MS = 2000; +const CLAIM_STEP = "claim"; +const FINISH_STEP = "finish"; + +export const useSetup = () => { + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const [restartAsked, setRestartAsked] = useState(false); + const [chosenStep, setChosenStep] = useState(null); + const [direction, setDirection] = useState<1 | -1>(1); + + const status = useQuery(orpc.instance.status.queryOptions()); + + const isClaimed = status.data?.claimed ?? false; + + const configuration = useQuery( + orpc.instance.describe.queryOptions({ + enabled: isClaimed, + refetchInterval: (query) => + restartAsked && query.state.data?.restartRequired !== false + ? RECONNECT_POLL_MS + : false, + retry: false, + }) + ); + + const integrationSteps = + configuration.data?.integrations.map((integration) => integration.id) ?? []; + + const steps = [ + ...(isClaimed ? [] : [CLAIM_STEP]), + ...integrationSteps, + FINISH_STEP, + ]; + + const stepId = chosenStep ?? steps[0] ?? CLAIM_STEP; + const stepIndex = Math.max(steps.indexOf(stepId), 0); + const restartRequired = configuration.data?.restartRequired ?? false; + + const goTo = (next: string, heading: 1 | -1) => { + setDirection(heading); + setChosenStep(next); + }; + + const handleSignOut = async () => { + await authClient.signOut(); + await navigate({ to: "/login" }); + }; + + const handleNext = () => { + const next = steps[stepIndex + 1]; + + if (next !== undefined) { + goTo(next, 1); + } + }; + + const handleBack = () => { + const previous = steps[stepIndex - 1]; + + if (previous !== undefined) { + goTo(previous, -1); + } + }; + + const refreshConfiguration = async () => { + await queryClient.invalidateQueries({ + queryKey: orpc.instance.describe.queryOptions().queryKey, + }); + }; + + const claim = useMutation( + orpc.instance.claim.mutationOptions({ + onError: () => toast.error(m.setup_claim_error()), + onSuccess: async () => { + toast.success(m.setup_claim_success()); + + await queryClient.invalidateQueries({ + queryKey: orpc.instance.status.queryOptions().queryKey, + }); + + await refreshConfiguration(); + setChosenStep(null); + setDirection(1); + }, + }) + ); + + const save = useMutation( + orpc.instance.save.mutationOptions({ + onSuccess: async (outcome) => { + if (outcome.outcome === "saved") { + setRestartAsked(false); + + await refreshConfiguration(); + handleNext(); + } + }, + }) + ); + + const restart = useMutation( + orpc.instance.restart.mutationOptions({ + onSuccess: () => { + setRestartAsked(true); + toast.success(m.setup_restart_pending()); + }, + }) + ); + + const complete = useMutation( + orpc.instance.complete.mutationOptions({ + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: orpc.instance.status.queryOptions().queryKey, + }); + + toast.success(m.setup_completed_toast()); + await navigate({ to: "/" }); + }, + }) + ); + + return { + claim, + complete, + configuration, + direction, + handleBack, + handleNext, + handleSignOut, + isClaimed, + isPending: status.isPending || (isClaimed && configuration.isPending), + isReconnecting: restartAsked && restartRequired, + restart, + restartRequired, + save, + stepId, + stepIndex, + steps, + }; +}; diff --git a/apps/web/src/pages/setup/ui/claim-step.tsx b/apps/web/src/pages/setup/ui/claim-step.tsx new file mode 100644 index 00000000..b3410fa0 --- /dev/null +++ b/apps/web/src/pages/setup/ui/claim-step.tsx @@ -0,0 +1,51 @@ +import { Button } from "@freenary/ui/components/button"; +import { + Field, + FieldDescription, + FieldLabel, +} from "@freenary/ui/components/field"; +import { Input } from "@freenary/ui/components/input"; +import { useId, useState } from "react"; + +import { m } from "@/paraglide/messages.js"; +import { WizardStepHeader } from "@/shared/ui/wizard-step-header"; + +interface ClaimStepProps { + isSubmitting: boolean; + onClaim: (token: string) => void; +} + +export const ClaimStep = ({ isSubmitting, onClaim }: ClaimStepProps) => { + const inputId = useId(); + const [token, setToken] = useState(""); + const trimmed = token.trim(); + + return ( +
+ + + {m.setup_claim_token_label()} + setToken(event.target.value)} + value={token} + /> + {m.setup_claim_token_hint()} + +
+ +
+
+ ); +}; diff --git a/apps/web/src/pages/setup/ui/finish-step.tsx b/apps/web/src/pages/setup/ui/finish-step.tsx new file mode 100644 index 00000000..bd75b008 --- /dev/null +++ b/apps/web/src/pages/setup/ui/finish-step.tsx @@ -0,0 +1,84 @@ +import { Button } from "@freenary/ui/components/button"; +import { useIcon } from "@freenary/ui/lib/icon-context"; + +import { m } from "@/paraglide/messages.js"; +import { WizardStepHeader } from "@/shared/ui/wizard-step-header"; + +import { integrationTitle, stateLabel } from "../model/labels"; +import type { SetupIntegrationDescriptor } from "./provider-step"; + +interface FinishStepProps { + integrations: SetupIntegrationDescriptor[]; + isCompleting: boolean; + isRestarting: boolean; + onBack: () => void; + onFinish: () => void; + onRestart: () => void; + restartRequired: boolean; +} + +export const FinishStep = ({ + integrations, + isCompleting, + isRestarting, + onBack, + onFinish, + onRestart, + restartRequired, +}: FinishStepProps) => { + const ArrowLeftIcon = useIcon("arrow-left"); + + return ( +
+ + +
+ {integrations.map((integration) => ( +
+
{integrationTitle(integration.id)}
+
+ {stateLabel(integration.state)} +
+
+ ))} +
+ + {restartRequired ? ( +
+

+ {m.setup_restart_description()} +

+ +
+ ) : null} + +
+ + +
+
+ ); +}; diff --git a/apps/web/src/pages/setup/ui/provider-step.tsx b/apps/web/src/pages/setup/ui/provider-step.tsx new file mode 100644 index 00000000..ef040c20 --- /dev/null +++ b/apps/web/src/pages/setup/ui/provider-step.tsx @@ -0,0 +1,216 @@ +import { Button } from "@freenary/ui/components/button"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, +} from "@freenary/ui/components/select"; +import { useIcon } from "@freenary/ui/lib/icon-context"; +import { useState } from "react"; + +import { m } from "@/paraglide/messages.js"; +import { WizardStepHeader } from "@/shared/ui/wizard-step-header"; + +import { + integrationDescription, + integrationTitle, + variantLabel, +} from "../model/labels"; +import type { SetupFieldDescriptor } from "./setup-field"; +import { SetupField } from "./setup-field"; + +export interface SetupVariantDescriptor { + fields: SetupFieldDescriptor[]; + id: string; +} + +export interface SetupIntegrationDescriptor { + discriminantSource: string; + id: string; + selectedVariantId: string; + state: string; + variants: SetupVariantDescriptor[]; +} + +export interface SaveOutcome { + detail?: string; + keys?: string[]; + outcome: string; + reason?: string; + status?: number | null; +} + +interface ProviderStepProps { + descriptor: SetupIntegrationDescriptor; + isFirstStep: boolean; + isSaving: boolean; + onBack: () => void; + onSave: (variantId: string, values: Record) => void; + onSkip: () => void; + outcome: SaveOutcome | undefined; +} + +const filledValuesOf = ( + variant: SetupVariantDescriptor | undefined +): Record => + Object.fromEntries( + (variant?.fields ?? []).flatMap((field) => + field.value === null ? [] : [[field.key, field.value]] + ) + ); + +const outcomeMessage = (outcome: SaveOutcome): string => { + if (outcome.outcome === "saved") { + return m.setup_saved(); + } + + if (outcome.outcome === "missing-fields") { + return m.setup_error_missing_fields({ + keys: (outcome.keys ?? []).join(", "), + }); + } + + if (outcome.outcome === "environment-owned") { + return m.setup_error_environment_owned({ + keys: (outcome.keys ?? []).join(", "), + }); + } + + if (outcome.outcome === "invalid") { + return m.setup_error_invalid({ detail: outcome.detail ?? "" }); + } + + if (outcome.reason === "rejected") { + return m.setup_error_probe_rejected({ + detail: outcome.detail ?? "", + status: outcome.status ?? 0, + }); + } + + if (outcome.reason === "invalid") { + return m.setup_error_probe_invalid({ detail: outcome.detail ?? "" }); + } + + return m.setup_error_probe_unreachable({ detail: outcome.detail ?? "" }); +}; + +export const ProviderStep = ({ + descriptor, + isFirstStep, + isSaving, + onBack, + onSave, + onSkip, + outcome, +}: ProviderStepProps) => { + const ArrowLeftIcon = useIcon("arrow-left"); + const [variantId, setVariantId] = useState(descriptor.selectedVariantId); + const [values, setValues] = useState>(() => + filledValuesOf( + descriptor.variants.find( + (entry) => entry.id === descriptor.selectedVariantId + ) + ) + ); + + const variant = descriptor.variants.find((entry) => entry.id === variantId); + const lockedByEnvironment = descriptor.discriminantSource === "environment"; + const isFailure = outcome !== undefined && outcome.outcome !== "saved"; + + return ( +
+ + + {lockedByEnvironment ? ( +

+ {m.setup_locked_by_environment()} +

+ ) : null} + +
+ + + {variant?.fields.map((field) => ( + + setValues((held) => ({ ...held, [key]: next })) + } + value={values[field.key]} + /> + ))} +
+ + {outcome ? ( +

+ {outcomeMessage(outcome)} +

+ ) : null} + +
+ {isFirstStep ? ( + + ) : ( + + )} +
+ + +
+
+
+ ); +}; diff --git a/apps/web/src/pages/setup/ui/setup-field.tsx b/apps/web/src/pages/setup/ui/setup-field.tsx new file mode 100644 index 00000000..54aa14fb --- /dev/null +++ b/apps/web/src/pages/setup/ui/setup-field.tsx @@ -0,0 +1,108 @@ +import { + Field, + FieldDescription, + FieldLabel, +} from "@freenary/ui/components/field"; +import { Input } from "@freenary/ui/components/input"; +import { Switch } from "@freenary/ui/components/switch"; +import { Textarea } from "@freenary/ui/components/textarea"; +import { useId } from "react"; + +import { m } from "@/paraglide/messages.js"; + +import { fieldLabel } from "../model/labels"; + +const TRUE = "true"; +const FALSE = "false"; +const SECRET_ROWS = 6; + +export interface SetupFieldDescriptor { + key: string; + kind: string; + present: boolean; + required: boolean; + source: string; + value: string | null; +} + +interface SetupFieldProps { + descriptor: SetupFieldDescriptor; + onChange: (key: string, value: string) => void; + value: string | undefined; +} + +const isSecret = (kind: string): boolean => + kind === "secret" || kind === "secret-block"; + +const inputType = (kind: string): string => { + if (kind === "secret") { + return "password"; + } + + return kind === "port" ? "number" : "text"; +}; + +export const SetupField = ({ + descriptor, + onChange, + value, +}: SetupFieldProps) => { + const inputId = useId(); + const ownedByEnvironment = descriptor.source === "environment"; + const hint = ownedByEnvironment + ? m.setup_source_environment({ key: descriptor.key }) + : descriptor.key; + + const secretPlaceholder = + isSecret(descriptor.kind) && descriptor.present + ? m.setup_secret_unchanged_hint() + : undefined; + + const held = value ?? descriptor.value ?? ""; + + if (descriptor.kind === "toggle") { + return ( + + {fieldLabel(descriptor.key)} + + onChange(descriptor.key, checked ? TRUE : FALSE) + } + /> + {hint} + + ); + } + + return ( + + + {fieldLabel(descriptor.key)} + {descriptor.required ? null : ` ${m.setup_field_optional()}`} + + {descriptor.kind === "secret-block" ? ( +