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/.env.example b/.env.example index 2af77b0b..a753bd5a 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,8 @@ POSTGRES_PASSWORD=password # Signs session cookies, encrypts two-factor secrets and backup codes, and signs the bank connection state. BETTER_AUTH_SECRET=dev_secret_change_me_at_least_32chars +# The setup token that claims an unclaimed instance on the setup screen. Unset, the server makes a new token at each start and prints it in its log. +# FREENARY_SETUP_TOKEN=choose_a_long_random_value_of_32_or_more_characters # --- Public origins and cookies --- # The origins a browser reaches. Both default to localhost outside production, diff --git a/apps/fumadocs/content/docs/next/contributing/data-model.mdx b/apps/fumadocs/content/docs/next/contributing/data-model.mdx index 643e523b..7b2e29d8 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 setup token, printed at start or read from `FREENARY_SETUP_TOKEN`, 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..532178bb 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. Freenary opens `/setup` for it. Read the setup link from the server log: + +```bash +docker compose logs server | grep "setup token" +``` + +Open the link, and press `Claim`. The link puts the token into the field for you. That account becomes the operator of this instance, and no second account can take the role. + +Now go to the **Bank provider** step, pick `Powens`, and press `Continue`. Type the three values from the console, and press `Save and continue`. Freenary calls your Powens domain with the credentials, and saves them when the call succeeds. + +Go to the last step, and press `Finish setup`. The server restarts to use the credentials, and the page opens the app when the server is back. + +[The setup screen](/docs/self-hosting/setup) holds each card, each check and the last step 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..22827630 100644 --- a/apps/fumadocs/content/docs/next/self-hosting/configuration.mdx +++ b/apps/fumadocs/content/docs/next/self-hosting/configuration.mdx @@ -28,11 +28,24 @@ 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 */} -The API server checks its environment once, at startup. The schema is [`packages/env/src/schema.ts`](https://github.com/suiramdev/freenary/blob/main/packages/env/src/schema.ts). It declares 48 variables with Zod, and `packages/env/src/server.ts` hands them to `@t3-oss/env-core`. The reference below, and `.env.example`, are generated from that one file. +The API server checks its environment once, at startup. The schema is [`packages/env/src/schema.ts`](https://github.com/suiramdev/freenary/blob/main/packages/env/src/schema.ts). It declares 49 variables with Zod, and `packages/env/src/server.ts` hands them to `@t3-oss/env-core`. The reference below, and `.env.example`, are generated from that one file. {/* env-sync:end */} @@ -101,6 +114,7 @@ Outside production the same text is a `console.warn` line. Compose always sets ` | Variable | Default | What it does | Absent or wrong | | --- | --- | --- | --- | | `BETTER_AUTH_SECRET` | `dev_secret_change_me_at_least_32chars` outside production, required in production | Signs session cookies, encrypts two-factor secrets and backup codes, and signs the bank connection state. | Under 32 characters stops the start. The development default applies outside production alone, so a production start with no value stops. | +| `FREENARY_SETUP_TOKEN` | unset | The setup token that claims an unclaimed instance on the setup screen. Unset, the server makes a new token at each start and prints it in its log. | Under 32 characters, or a character other than a letter, a digit, `-` or `_`, stops the start. After the claim the server ignores the value. | | `NODE_ENV` | `development` | Picks the runtime mode: `development`, `production` or `test`. | `production` drops the log file drain and turns the cookie warning into a refusal. `test` turns the sign-in rate limits off. Compose always sets `production`. | | `PORT` | `3000` | The port the API server listens on. It also fills the `BETTER_AUTH_URL` default. | A non-digit character stops the start. In the Docker stack it must stay `3000`, because the healthcheck fetches port 3000 inside the container. Never put it in the root `.env` file, which the `server` service loads whole. | 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..4613ff5b 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,30 @@ 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. Freenary opens `/setup` for it. Read the setup link from the server log: + +```bash +docker compose logs server | grep "setup token" +``` + +Open the link, and press `Claim`. 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. + +To know the token before the first start, set `FREENARY_SETUP_TOKEN`. [The setup screen](/docs/self-hosting/setup#choose-the-token-in-advance) tells how. + +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 +214,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 +137,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 +153,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..96a5c540 --- /dev/null +++ b/apps/fumadocs/content/docs/next/self-hosting/setup.mdx @@ -0,0 +1,181 @@ +--- +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 can test each credential against the real service. `Check` makes the call and writes nothing. `Save` stores the values and makes no call. + +## 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 | +| `FREENARY_SETUP_TOKEN` | Optional. It sets the setup token before the first start, so you do not need the log to claim the instance | + +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 setup token that only the operator knows. + + + + + +### Get the setup link + +Each start of an unclaimed instance prints a setup link to the server log: + +```bash +docker compose logs server | grep "setup token" +``` + +The line holds the web app origin and the token: + +```text +This instance is unclaimed. Open this link to claim it with its setup token: https://app.example.com/setup#token=s6kSPDviSE5J_r3wgUos1a06mq9Z1qmt +``` + +The server makes a new token at each start while the instance is unclaimed. Only the hash of it goes into the database. A restart therefore makes the last link useless. Read the log again after a restart. + +The token sits after the `#` in the link. The browser does not send that part to any server. The token therefore stays out of the logs of your reverse proxy. The web app removes it from the address bar when the page opens. + + + + + +### Open the link, sign up, then claim + +Open the link, and create an account in the usual way. The web app keeps the token while you sign up, then opens `/setup` with the token in the field. Press `Claim`. + +You can also open `/setup` directly and type the token. + +That account becomes the operator. The server prints no link after the claim, and no second account can take the role. + + + + + +### Choose the token in advance + +Set `FREENARY_SETUP_TOKEN` in `.env` to choose the token yourself. Use a random value of 32 or more characters: + +```bash +openssl rand -hex 32 +``` + +The server then uses that value at each start, and a restart keeps it valid. The log line names the variable in place of the value. The server never prints a secret from the environment. Open `https://app.example.com/setup#token=` followed by your value. + +After the claim, the server ignores the variable. You can remove the line from `.env`. + +## Walk the steps + +The wizard runs `Claim`, then one step per integration, then `Done`. Each integration step has two screens. The first screen asks which provider to use. The second screen asks for the credentials of that provider. + +**Every integration step is optional.** Pick `Skip for now` and press `Continue` to go to the next step. When the app holds no provider for that integration, a skip writes nothing. When it holds one, a skip clears it. `Back` returns to the last screen. + + + + + +### Pick the provider + +**Bank provider** offers Powens and Enable Banking. **Email delivery** offers Resend and SMTP. The screen selects a provider for you: the one already in use, or else the first one. Press `Continue`. + + + + + +### Fill the fields, then press `Save and continue` + +The screen shows the required fields. The optional fields, such as the SMTP port or user, are under `More options`. A link opens the setup guide of the provider. + +`Save and continue` first calls the service with the credentials that the server would use: + +| 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 | + +When the call succeeds, the screen saves the values and goes to the next step. When the call fails, the screen tells you what to change and saves nothing. `Show details` gives the exact answer of the service. + +After a failed call, the screen also offers `Save anyway`. It stores the values with no call, for a service that you cannot reach from the server yet. Such a save can hold a wrong credential. Come back and save again when the service is available. + + + + + +### Review, then press `Finish setup` + +The last step shows each integration, the provider in use, and its state: `Configured`, `Incomplete` or `Off`. + +The API server reads its configuration one time, at startup. When you saved a value that the server does not use yet, `Finish setup` restarts the server. The screen then waits, and opens the app when the server is back. When nothing changed, `Finish setup` opens the app at once. + +The restart needs a supervisor that starts the server again. The compose files set `restart: unless-stopped` for this. If the server does not come back within one minute, read [the restart fix](/docs/self-hosting/troubleshooting#the-server-does-not-restart-after-setup). + +After `Finish setup`, the instance stops sending visitors to `/setup`. 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 screen therefore shows such a value as read-only, with the tag `Set by your server`. + +- `Save and continue` tests the environment values together with the values you typed. It stores only the fields that the environment does not set. +- The environment can configure a provider in full. This is true when the app holds no provider for that integration, and the environment sets every required field. The step then shows a card, for example `Powens is ready`, in place of the two screens. `Show details` lists the values and the variables they come from. It also says when the provider is the default, because `BANKING_PROVIDER` is not set. `Test connection` calls the service with those values. +- The screen shows the first four and the last four characters of a value from the environment. A value of eight characters or fewer shows as `…`. The screen never shows a secret from the environment. + +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/fumadocs/content/docs/next/self-hosting/troubleshooting.mdx b/apps/fumadocs/content/docs/next/self-hosting/troubleshooting.mdx index 481ab474..b2677823 100644 --- a/apps/fumadocs/content/docs/next/self-hosting/troubleshooting.mdx +++ b/apps/fumadocs/content/docs/next/self-hosting/troubleshooting.mdx @@ -81,6 +81,26 @@ Do not set `SKIP_ENV_VALIDATION` to get past this report. The flag hides the mis +## The server does not restart after setup + +**What you see.** After `Finish setup`, the setup screen waits for one minute, then shows `The server did not restart`. + +**Why.** `Finish setup` stops the API server, so that it reads the saved configuration at the next start. A supervisor must start it again. The compose files set `restart: unless-stopped` for this. A server that runs with no supervisor, for example with `bun run dev`, stays stopped. A server that fails at startup also stays down. + +**Fix.** Read the log of the server: + +```bash +docker compose logs server +``` + +Correct the error that the log names, then start the server: + +```bash +docker compose up -d server +``` + +Press `Try again` on the setup screen. It waits for the server again, and opens the app when the server is back. + ## The server refuses a proxy address **What you see.** The report above names `TRUSTED_PROXIES`, with this message: 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..db568f7f 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,18 @@ new Elysia() .listen(env.PORT, () => { console.log(`Server is running on http://localhost:${env.PORT}`); }); + +const issuedSetupToken = await Effect.runPromise( + issueSetupToken(env.FREENARY_SETUP_TOKEN) +); + +if (Option.isSome(issuedSetupToken)) { + const setupUrl = `${env.CORS_ORIGIN}/setup#token=`; + const issued = issuedSetupToken.value; + + console.log( + issued.kind === "generated" + ? `This instance is unclaimed. Open this link to claim it with its setup token: ${setupUrl}${issued.token}` + : `This instance is unclaimed. To claim it with the setup token from FREENARY_SETUP_TOKEN, open ${setupUrl} followed by that value.` + ); +} diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index c26b6a7c..0a8abdef 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,99 @@ } } ], + "setup_applying_description": "This page reconnects on its own.", + "setup_applying_title": "Applying your settings…", + "setup_back": "Back", + "setup_check": "Check", + "setup_claim_description": "Enter the setup token from your server log.", + "setup_claim_error": "That token was refused. Read the server log again and retry.", + "setup_claim_submit": "Claim instance", + "setup_claim_success": "You are now the operator of this instance.", + "setup_claim_title": "Claim this instance", + "setup_claim_token_help": "Where do I find the token?", + "setup_claim_token_hint": "Read the link from the server log with docker compose logs server. If the environment sets FREENARY_SETUP_TOKEN, enter that value.", + "setup_claim_token_label": "Setup token", + "setup_completed_toast": "The instance is set up.", + "setup_continue": "Continue", + "setup_credentials_description": "Enter the credentials for {provider}.", + "setup_credentials_title": "Connect {provider}", + "setup_description": "Choose the services this instance uses and enter their credentials. Freenary checks each one before it saves.", + "setup_environment_default_provider": "{key} is not set, so {provider} is the default.", + "setup_environment_learn_more": "Learn more about environment settings", + "setup_environment_line": "Set by your server's environment variables.", + "setup_environment_note": "These values come from environment variables: {keys}. The server always uses them, so you cannot change them here. To change them, edit the variables and restart the server.", + "setup_environment_ready": "{provider} is ready", + "setup_environment_unset": "Not set", + "setup_environment_variables": "Variables: {keys}", + "setup_error_environment_owned": "Your server's environment sets this provider.", + "setup_error_invalid": "A value has the wrong format. Check the fields and try again.", + "setup_error_missing_fields": "Fill in {keys}.", + "setup_error_probe_invalid": "A value has the wrong shape: {detail}", + "setup_error_probe_rejected": "{provider} refused these credentials. Check them and try again.", + "setup_error_probe_unreachable": "Unable to reach {provider}. Check the address and your network, then try again.", + "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": "Check your choices, then finish.", + "setup_finish_title": "Review and finish", + "setup_integration_banking_description": "Pick the service that connects bank accounts.", + "setup_integration_banking_title": "Bank provider", + "setup_integration_email_description": "Pick the service that sends sign-in and account emails.", + "setup_integration_email_title": "Email delivery", + "setup_loading": "Loading the setup screen", + "setup_locked_by_environment": "The environment sets this integration. The server always uses the environment value, so you cannot change it here. You can still check it. To set it here, remove its lines from the .env file.", + "setup_more_options": "More options", + "setup_next": "Next", + "setup_not_operator_description": "Another account operates this instance. Ask its operator to change the configuration.", + "setup_not_operator_title": "Already claimed", + "setup_nothing_to_save": "The environment sets every value, so there is nothing to save.", + "setup_progress_label": "Setup progress", + "setup_recap_from_environment": "From the environment", + "setup_save": "Save", + "setup_save_and_continue": "Save and continue", + "setup_save_anyway": "Save anyway", + "setup_saved": "Saved after a check. It applies when you finish setup.", + "setup_saved_unchecked": "Saved without a connection test.", + "setup_secret_from_environment": "Set in the environment", + "setup_secret_unchanged_hint": "Saved. Leave empty to keep it.", + "setup_set_by_server": "Set by your server", + "setup_show_details": "Show details", + "setup_skip": "Skip", + "setup_source_environment": "{key} comes from the environment. The server always uses that value.", + "setup_stalled_description": "It did not answer within one minute.", + "setup_stalled_title": "The server did not restart", + "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_test_connection": "Test connection", + "setup_title": "Set up this instance", + "setup_try_again": "Try again", + "setup_variant_disabled": "Skip for now", + "setup_variant_enable_banking": "Enable Banking", + "setup_variant_guide": "How to get {provider} credentials", + "setup_variant_label": "Provider", + "setup_variant_powens": "Powens", + "setup_variant_resend": "Resend", + "setup_variant_smtp": "SMTP", + "setup_verified": "Connection works.", + "setup_view_troubleshooting": "View troubleshooting", "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..be0450b0 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,99 @@ } } ], + "setup_applying_description": "Cette page se reconnecte d'elle-même.", + "setup_applying_title": "Application de vos réglages…", + "setup_back": "Retour", + "setup_check": "Vérifier", + "setup_claim_description": "Saisissez le jeton d'installation indiqué dans le journal du serveur.", + "setup_claim_error": "Ce jeton a été refusé. Relisez le journal du serveur et réessayez.", + "setup_claim_submit": "Revendiquer l'instance", + "setup_claim_success": "Vous êtes désormais l'opérateur de cette instance.", + "setup_claim_title": "Revendiquer cette instance", + "setup_claim_token_help": "Où trouver le jeton ?", + "setup_claim_token_hint": "Lisez le lien dans le journal du serveur avec docker compose logs server. Si l'environnement définit FREENARY_SETUP_TOKEN, saisissez cette valeur.", + "setup_claim_token_label": "Jeton d'installation", + "setup_completed_toast": "L'instance est configurée.", + "setup_continue": "Continuer", + "setup_credentials_description": "Saisissez les identifiants de {provider}.", + "setup_credentials_title": "Connecter {provider}", + "setup_description": "Choisissez les services utilisés par cette instance et saisissez leurs identifiants. Freenary vérifie chacun d'eux avant d'enregistrer.", + "setup_environment_default_provider": "{key} n'est pas défini : {provider} est le choix par défaut.", + "setup_environment_learn_more": "En savoir plus sur les réglages d'environnement", + "setup_environment_line": "Défini par les variables d'environnement de votre serveur.", + "setup_environment_note": "Ces valeurs proviennent de variables d'environnement : {keys}. Le serveur les utilise toujours, vous ne pouvez donc pas les modifier ici. Pour les changer, modifiez les variables et redémarrez le serveur.", + "setup_environment_ready": "{provider} est prêt", + "setup_environment_unset": "Non défini", + "setup_environment_variables": "Variables : {keys}", + "setup_error_environment_owned": "L'environnement de votre serveur définit ce fournisseur.", + "setup_error_invalid": "Une valeur n'a pas le bon format. Vérifiez les champs et réessayez.", + "setup_error_missing_fields": "Renseignez {keys}.", + "setup_error_probe_invalid": "Une valeur n'a pas le bon format : {detail}", + "setup_error_probe_rejected": "{provider} a refusé ces identifiants. Vérifiez-les et réessayez.", + "setup_error_probe_unreachable": "Impossible de joindre {provider}. Vérifiez l'adresse et votre réseau, puis réessayez.", + "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": "Vérifiez vos choix, puis terminez.", + "setup_finish_title": "Vérifier et terminer", + "setup_integration_banking_description": "Choisissez le service qui connecte les comptes bancaires.", + "setup_integration_banking_title": "Fournisseur bancaire", + "setup_integration_email_description": "Choisissez le service qui envoie les courriels de connexion et de compte.", + "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. Le serveur utilise toujours la valeur de l'environnement, vous ne pouvez donc pas la modifier ici. Vous pouvez tout de même la vérifier. Pour la définir ici, retirez ses lignes du fichier .env.", + "setup_more_options": "Plus d'options", + "setup_next": "Suivant", + "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_nothing_to_save": "L'environnement définit toutes les valeurs : il n'y a rien à enregistrer.", + "setup_progress_label": "Progression de la configuration", + "setup_recap_from_environment": "Depuis l'environnement", + "setup_save": "Enregistrer", + "setup_save_and_continue": "Enregistrer et continuer", + "setup_save_anyway": "Enregistrer quand même", + "setup_saved": "Enregistré après vérification. Appliqué à la fin de la configuration.", + "setup_saved_unchecked": "Enregistré sans test de connexion.", + "setup_secret_from_environment": "Défini dans l'environnement", + "setup_secret_unchanged_hint": "Enregistré. Laissez vide pour le conserver.", + "setup_set_by_server": "Défini par votre serveur", + "setup_show_details": "Afficher les détails", + "setup_skip": "Passer", + "setup_source_environment": "{key} provient de l'environnement. Le serveur utilise toujours cette valeur.", + "setup_stalled_description": "Il n'a pas répondu en une minute.", + "setup_stalled_title": "Le serveur n'a pas redémarré", + "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_test_connection": "Tester la connexion", + "setup_title": "Configurer cette instance", + "setup_try_again": "Réessayer", + "setup_variant_disabled": "Passer pour l'instant", + "setup_variant_enable_banking": "Enable Banking", + "setup_variant_guide": "Obtenir les identifiants {provider}", + "setup_variant_label": "Fournisseur", + "setup_variant_powens": "Powens", + "setup_variant_resend": "Resend", + "setup_variant_smtp": "SMTP", + "setup_verified": "La connexion fonctionne.", + "setup_view_troubleshooting": "Voir le dépannage", "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..b14c2691 100644 --- a/apps/web/src/app/routes/__root.tsx +++ b/apps/web/src/app/routes/__root.tsx @@ -14,7 +14,9 @@ import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; import { createMiddleware } from "@tanstack/react-start"; import { evlogErrorHandler } from "evlog/nitro/v3"; import { ThemeProvider } from "next-themes"; +import { useEffect } from "react"; +import { watchSetupTokenLink } from "@/pages/setup"; import { m } from "@/paraglide/messages.js"; import { getLocale } from "@/paraglide/runtime.js"; import type { orpc } from "@/shared/api"; @@ -22,6 +24,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"; @@ -31,62 +34,66 @@ export interface RouterAppContext { queryClient: QueryClient; } -const RootDocument = () => ( - - - - - - - - - - - - - - - - - - - - -); +const RootDocument = () => { + useEffect(watchSetupTokenLink, []); + + return ( + + + + + + + + + + + + + + + + + + + + + ); +}; export const Route = createRootRouteWithContext()({ server: { @@ -94,6 +101,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/features/auth-gate/ui/auth-gate.tsx b/apps/web/src/features/auth-gate/ui/auth-gate.tsx index 3816a929..822cf257 100644 --- a/apps/web/src/features/auth-gate/ui/auth-gate.tsx +++ b/apps/web/src/features/auth-gate/ui/auth-gate.tsx @@ -8,7 +8,7 @@ import { authClient } from "@/shared/auth"; type Audience = "guest" | "member" | "onboarding"; -type Destination = "/" | "/login" | "/onboarding"; +type Destination = "/" | "/login" | "/onboarding" | "/setup"; interface AuthGateProps { audience: Audience; @@ -18,12 +18,20 @@ interface AuthGateProps { interface Visitor { completed: boolean | undefined; isPending: boolean; + isSetupUnsettled: boolean; isSignedIn: boolean; + setupCompleted: boolean | undefined; } const redirectFor = ( audience: Audience, - { completed, isPending, isSignedIn }: Visitor + { + completed, + isPending, + isSetupUnsettled, + isSignedIn, + setupCompleted, + }: Visitor ): Destination | null => { if (audience === "guest") { return isSignedIn ? "/" : null; @@ -37,6 +45,14 @@ const redirectFor = ( return "/login"; } + if (setupCompleted === false) { + return "/setup"; + } + + if (isSetupUnsettled) { + return null; + } + if (audience === "member") { return completed === false ? "/onboarding" : null; } @@ -45,21 +61,35 @@ const redirectFor = ( }; export const AuthGate = ({ audience, children }: AuthGateProps) => { - const viewer = useRouteContext({ + const { instance, viewer } = useRouteContext({ from: "__root__", - select: (context) => context.viewer, + select: (context) => ({ + instance: context.instance, + viewer: context.viewer, + }), }); const navigate = useNavigate(); const { data: session, isPending } = authClient.useSession(); + const isSignedIn = session !== null && session !== undefined; const status = useQuery( orpc.onboarding.getStatus.queryOptions({ enabled: session !== null }) ); + const setup = useQuery( + orpc.instance.status.queryOptions({ + enabled: audience !== "guest" && isSignedIn, + }) + ); + + const isSetupUnsettled = instance.kind !== "ready" && setup.isPending; + const destination = redirectFor(audience, { completed: status.data?.completed, isPending, + isSetupUnsettled, isSignedIn: session !== null, + setupCompleted: setup.data?.completed, }); useEffect(() => { @@ -84,6 +114,10 @@ export const AuthGate = ({ audience, children }: AuthGateProps) => { return wasServerRenderedForMember ? children : null; } + if (isSetupUnsettled) { + return null; + } + const isOnboardingStatusUnreadable = audience === "member" && status.isError; if (isOnboardingStatusUnreadable) { 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/api/wait-for-server.ts b/apps/web/src/pages/setup/api/wait-for-server.ts new file mode 100644 index 00000000..c70e4ef5 --- /dev/null +++ b/apps/web/src/pages/setup/api/wait-for-server.ts @@ -0,0 +1,31 @@ +import { Data, Effect, Schedule } from "effect"; + +import { client } from "@/shared/api"; + +const POLL_INTERVAL = "2 seconds"; +const GIVE_UP_AFTER = "60 seconds"; + +class ServerNotApplied extends Data.TaggedError("ServerNotApplied")<{ + readonly cause: unknown; +}> {} + +const describeAppliedServer = Effect.tryPromise({ + catch: (cause) => new ServerNotApplied({ cause }), + try: () => client.instance.describe(), +}).pipe( + Effect.flatMap((described) => + described.restartRequired + ? Effect.fail(new ServerNotApplied({ cause: "restart pending" })) + : Effect.void + ) +); + +export const waitForAppliedServer = (): Promise => + Effect.runPromise( + describeAppliedServer.pipe( + Effect.delay(POLL_INTERVAL), + Effect.retry(Schedule.spaced(POLL_INTERVAL)), + Effect.timeout(GIVE_UP_AFTER), + Effect.match({ onFailure: () => false, onSuccess: () => true }) + ) + ); diff --git a/apps/web/src/pages/setup/index.ts b/apps/web/src/pages/setup/index.ts new file mode 100644 index 00000000..b28a5f77 --- /dev/null +++ b/apps/web/src/pages/setup/index.ts @@ -0,0 +1,2 @@ +export { watchSetupTokenLink } from "./lib/setup-token-link"; +export { SetupPage } from "./ui/setup-page"; diff --git a/apps/web/src/pages/setup/lib/setup-token-link.ts b/apps/web/src/pages/setup/lib/setup-token-link.ts new file mode 100644 index 00000000..5ffa62dc --- /dev/null +++ b/apps/web/src/pages/setup/lib/setup-token-link.ts @@ -0,0 +1,69 @@ +import { isServer } from "@/shared/lib/is-server"; + +const STORAGE_KEY = "freenary:setup-token"; +const FRAGMENT_PREFIX = "#token="; +const WELL_FORMED_TOKEN = /^[\w-]{32,}$/u; + +const listeners = new Set<() => void>(); + +const tokenInFragment = (): string | null => { + const { hash } = window.location; + + return hash.startsWith(FRAGMENT_PREFIX) + ? hash.slice(FRAGMENT_PREFIX.length) + : null; +}; + +const wellFormedTokenInFragment = (): string | null => { + const token = tokenInFragment(); + + return token !== null && WELL_FORMED_TOKEN.test(token) ? token : null; +}; + +const captureSetupTokenLink = () => { + if (tokenInFragment() === null) { + return; + } + + const token = wellFormedTokenInFragment(); + + if (token !== null) { + sessionStorage.setItem(STORAGE_KEY, token); + } + + window.history.replaceState( + window.history.state, + "", + `${window.location.pathname}${window.location.search}` + ); + + for (const notify of listeners) { + notify(); + } +}; + +export const watchSetupTokenLink = () => { + captureSetupTokenLink(); + window.addEventListener("hashchange", captureSetupTokenLink); + + return () => window.removeEventListener("hashchange", captureSetupTokenLink); +}; + +export const subscribeToSetupTokenLink = (onLink: () => void) => { + listeners.add(onLink); + + return () => { + listeners.delete(onLink); + }; +}; + +export const readLinkedSetupToken = (): string => + isServer + ? "" + : (wellFormedTokenInFragment() ?? + sessionStorage.getItem(STORAGE_KEY) ?? + ""); + +export const clearLinkedSetupToken = () => { + sessionStorage.removeItem(STORAGE_KEY); +}; 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..fa0efb64 --- /dev/null +++ b/apps/web/src/pages/setup/model/labels.ts @@ -0,0 +1,107 @@ +import type { BadgeColor } from "@freenary/ui/components/badge"; + +import { m } from "@/paraglide/messages.js"; +import { docsUrl } from "@/shared/config"; + +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 VARIANT_GUIDE_PATHS: Record = { + "enable-banking": "/self-hosting/bank-providers#enable-banking", + powens: "/self-hosting/bank-providers#powens", + resend: "/self-hosting/email#resend", + smtp: "/self-hosting/email#smtp", +}; + +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"; + +const SETUP_TOKEN_DOCS_PATH = "/self-hosting/setup#get-the-setup-link"; + +const ENVIRONMENT_DOCS_PATH = + "/self-hosting/setup#a-value-in-the-environment-wins"; + +const RESTART_DOCS_PATH = + "/self-hosting/troubleshooting#the-server-does-not-restart-after-setup"; + +export const setupTokenDocsUrl = (): string => + `${docsUrl()}${SETUP_TOKEN_DOCS_PATH}`; + +export const environmentDocsUrl = (): string => + `${docsUrl()}${ENVIRONMENT_DOCS_PATH}`; + +export const restartDocsUrl = (): string => `${docsUrl()}${RESTART_DOCS_PATH}`; + +export const variantGuideUrl = (id: string): string | null => { + const path = VARIANT_GUIDE_PATHS[id]; + + return path === undefined ? null : `${docsUrl()}${path}`; +}; diff --git a/apps/web/src/pages/setup/model/outcome.ts b/apps/web/src/pages/setup/model/outcome.ts new file mode 100644 index 00000000..ae1373ef --- /dev/null +++ b/apps/web/src/pages/setup/model/outcome.ts @@ -0,0 +1,78 @@ +import { m } from "@/paraglide/messages.js"; + +import { fieldLabel } from "./labels"; + +export interface SaveOutcome { + checked?: boolean; + detail?: string; + keys?: string[]; + outcome: string; + reason?: string; + status?: number | null; +} + +const SUCCESS_OUTCOMES: ReadonlySet = new Set([ + "saved", + "verified", + "nothing-to-save", +]); + +export const isFailedOutcome = (outcome: SaveOutcome | undefined): boolean => + outcome !== undefined && !SUCCESS_OUTCOMES.has(outcome.outcome); + +const fieldList = (keys: readonly string[] | undefined): string => + (keys ?? []).map(fieldLabel).join(", "); + +const probeFailureMessage = ( + outcome: SaveOutcome, + provider: string +): string => { + if (outcome.reason === "rejected") { + return m.setup_error_probe_rejected({ provider }); + } + + if (outcome.reason === "invalid") { + return m.setup_error_invalid(); + } + + return m.setup_error_probe_unreachable({ provider }); +}; + +export const outcomeMessage = ( + outcome: SaveOutcome, + provider: string +): string => { + if (outcome.outcome === "verified") { + return m.setup_verified(); + } + + if (outcome.outcome === "saved" || outcome.outcome === "nothing-to-save") { + return outcome.checked === true + ? m.setup_verified() + : m.setup_saved_unchecked(); + } + + if (outcome.outcome === "missing-fields") { + return m.setup_error_missing_fields({ keys: fieldList(outcome.keys) }); + } + + if (outcome.outcome === "environment-owned") { + return m.setup_error_environment_owned(); + } + + if (outcome.outcome === "invalid") { + return m.setup_error_invalid(); + } + + return probeFailureMessage(outcome, provider); +}; + +export const outcomeDetail = (outcome: SaveOutcome): string | null => { + if (outcome.detail === undefined || outcome.detail === "") { + return null; + } + + return outcome.status === null || outcome.status === undefined + ? outcome.detail + : `${outcome.status}: ${outcome.detail}`; +}; 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..59464b67 --- /dev/null +++ b/apps/web/src/pages/setup/model/use-setup.ts @@ -0,0 +1,177 @@ +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"; + +import { waitForAppliedServer } from "../api/wait-for-server"; +import { clearLinkedSetupToken } from "../lib/setup-token-link"; + +const CLAIM_STEP = "claim"; + +const ADVANCING_OUTCOMES: ReadonlySet = new Set([ + "saved", + "nothing-to-save", +]); + +const FINISH_STEP = "finish"; + +export const useSetup = () => { + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const [restartPhase, setRestartPhase] = useState< + "idle" | "reconnecting" | "stalled" + >("idle"); + + 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 && restartPhase === "idle", + 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 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 () => { + clearLinkedSetupToken(); + 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") { + await refreshConfiguration(); + } + + if (ADVANCING_OUTCOMES.has(outcome.outcome)) { + handleNext(); + } + }, + }) + ); + + const check = useMutation(orpc.instance.check.mutationOptions()); + + const enterApp = async () => { + await queryClient.invalidateQueries({ + queryKey: orpc.instance.status.queryOptions().queryKey, + }); + + toast.success(m.setup_completed_toast()); + await navigate({ to: "/" }); + }; + + const reconnect = async () => { + setRestartPhase("reconnecting"); + + const isBack = await waitForAppliedServer(); + + if (!isBack) { + setRestartPhase("stalled"); + + return; + } + + await refreshConfiguration(); + await enterApp(); + }; + + const complete = useMutation( + orpc.instance.complete.mutationOptions({ + onSuccess: async ({ restarting }) => { + if (restarting) { + await reconnect(); + + return; + } + + await enterApp(); + }, + }) + ); + + return { + check, + claim, + complete, + configuration, + direction, + handleBack, + handleNext, + handleSignOut, + isClaimed, + isPending: status.isPending || (isClaimed && configuration.isPending), + reconnect, + restartPhase, + 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..371e8ec0 --- /dev/null +++ b/apps/web/src/pages/setup/ui/claim-step.tsx @@ -0,0 +1,66 @@ +import { Button } from "@freenary/ui/components/button"; +import { Field, FieldLabel } from "@freenary/ui/components/field"; +import { Input } from "@freenary/ui/components/input"; +import { useEffect, useId, useState } from "react"; + +import { m } from "@/paraglide/messages.js"; +import { WizardStepHeader } from "@/shared/ui/wizard-step-header"; + +import { + readLinkedSetupToken, + subscribeToSetupTokenLink, +} from "../lib/setup-token-link"; +import { setupTokenDocsUrl } from "../model/labels"; + +interface ClaimStepProps { + isSubmitting: boolean; + onClaim: (token: string) => void; +} + +export const ClaimStep = ({ isSubmitting, onClaim }: ClaimStepProps) => { + const inputId = useId(); + const [token, setToken] = useState(readLinkedSetupToken); + + useEffect( + () => subscribeToSetupTokenLink(() => setToken(readLinkedSetupToken())), + [] + ); + + const trimmed = token.trim(); + + return ( +
+ + + {m.setup_claim_token_label()} + setToken(event.target.value)} + value={token} + /> + +
+ + {m.setup_claim_token_help()} + + +
+
+ ); +}; diff --git a/apps/web/src/pages/setup/ui/environment-summary.tsx b/apps/web/src/pages/setup/ui/environment-summary.tsx new file mode 100644 index 00000000..72df4c1f --- /dev/null +++ b/apps/web/src/pages/setup/ui/environment-summary.tsx @@ -0,0 +1,153 @@ +import { Button } from "@freenary/ui/components/button"; +import { Elevated } from "@freenary/ui/lib/elevated"; +import { useIcon } from "@freenary/ui/lib/icon-context"; +import { RiCheckLine } from "@remixicon/react"; + +import { m } from "@/paraglide/messages.js"; +import { remixIcon } from "@/shared/lib/remix-icon"; +import { WizardStepHeader } from "@/shared/ui/wizard-step-header"; + +import { + environmentDocsUrl, + integrationDescription, + integrationTitle, + variantLabel, +} from "../model/labels"; +import type { SaveOutcome } from "../model/outcome"; +import { OutcomeNotice } from "./outcome-notice"; +import type { SetupIntegrationDescriptor } from "./provider-step"; +import { ServerValueRow } from "./server-value-row"; + +const ENVIRONMENT_SOURCE = "environment"; +const SURFACE_RADIUS = "rounded-xl"; +const SURFACE_STEP = 1; +const READY_ICON_SIZE = 16; +const CheckIcon = remixIcon(RiCheckLine); + +interface EnvironmentSummaryProps { + descriptor: SetupIntegrationDescriptor; + isChecking: boolean; + isFirstStep: boolean; + onBack: () => void; + onCheck: (variantId: string) => void; + onNext: () => void; + outcome: SaveOutcome | undefined; +} + +export const EnvironmentSummary = ({ + descriptor, + isChecking, + isFirstStep, + onBack, + onCheck, + onNext, + outcome, +}: EnvironmentSummaryProps) => { + const ArrowLeftIcon = useIcon("arrow-left"); + const variantId = descriptor.selectedVariantId; + const provider = variantLabel(variantId); + const variant = descriptor.variants.find((entry) => entry.id === variantId); + const fields = variant?.fields ?? []; + const providerIsDefault = + descriptor.discriminantSource !== ENVIRONMENT_SOURCE; + + const environmentKeys = [ + ...(providerIsDefault ? [] : [descriptor.discriminantKey]), + ...fields.flatMap((field) => + field.source === ENVIRONMENT_SOURCE ? [field.key] : [] + ), + ]; + + return ( +
+ + + +
+
+ + + +
+

+ {m.setup_environment_ready({ provider })} +

+

+ {m.setup_environment_line()}{" "} + + {m.setup_environment_learn_more()} + +

+
+
+ +
+ + {m.setup_show_details()} + +
+ {fields.map((field) => ( + + ))} +
+

+ {m.setup_environment_variables({ + keys: environmentKeys.join(", "), + })} +

+ {providerIsDefault ? ( +

+ {m.setup_environment_default_provider({ + key: descriptor.discriminantKey, + provider, + })} +

+ ) : null} +
+
+
+ + + +
+ {isFirstStep ? ( + + ) : ( + + )} +
+ + +
+
+
+ ); +}; 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..00efcd1e --- /dev/null +++ b/apps/web/src/pages/setup/ui/finish-step.tsx @@ -0,0 +1,86 @@ +import { Badge } from "@freenary/ui/components/badge"; +import { Button } from "@freenary/ui/components/button"; +import { Elevated } from "@freenary/ui/lib/elevated"; +import { useIcon } from "@freenary/ui/lib/icon-context"; + +import { m } from "@/paraglide/messages.js"; +import { WizardStepHeader } from "@/shared/ui/wizard-step-header"; + +import { + integrationTitle, + stateColour, + stateLabel, + variantLabel, +} from "../model/labels"; +import type { SetupIntegrationDescriptor } from "./provider-step"; + +const DISABLED_STATE = "disabled"; +const SURFACE_RADIUS = "rounded-xl"; +const SURFACE_STEP = 1; + +interface FinishStepProps { + integrations: SetupIntegrationDescriptor[]; + isCompleting: boolean; + onBack: () => void; + onFinish: () => void; +} + +export const FinishStep = ({ + integrations, + isCompleting, + onBack, + onFinish, +}: FinishStepProps) => { + const ArrowLeftIcon = useIcon("arrow-left"); + + return ( +
+ + + +
+ {integrations.map((integration) => ( +
+
{integrationTitle(integration.id)}
+
+ {integration.state === DISABLED_STATE ? null : ( + + {variantLabel(integration.selectedVariantId)} + + )} + {integration.configuredBy === "environment" ? ( + + {m.setup_set_by_server()} + + ) : null} + + {stateLabel(integration.state)} + +
+
+ ))} +
+
+ +
+ + +
+
+ ); +}; diff --git a/apps/web/src/pages/setup/ui/guide-link.tsx b/apps/web/src/pages/setup/ui/guide-link.tsx new file mode 100644 index 00000000..e6c8f019 --- /dev/null +++ b/apps/web/src/pages/setup/ui/guide-link.tsx @@ -0,0 +1,33 @@ +import { RiArrowRightUpLine } from "@remixicon/react"; + +import { m } from "@/paraglide/messages.js"; +import { remixIcon } from "@/shared/lib/remix-icon"; + +import { variantGuideUrl, variantLabel } from "../model/labels"; + +const EXTERNAL_ICON_SIZE = 14; +const ExternalIcon = remixIcon(RiArrowRightUpLine); + +interface GuideLinkProps { + variantId: string; +} + +export const GuideLink = ({ variantId }: GuideLinkProps) => { + const guideUrl = variantGuideUrl(variantId); + + if (guideUrl === null) { + return null; + } + + return ( + + {m.setup_variant_guide({ provider: variantLabel(variantId) })} + + + ); +}; diff --git a/apps/web/src/pages/setup/ui/outcome-notice.tsx b/apps/web/src/pages/setup/ui/outcome-notice.tsx new file mode 100644 index 00000000..112d458f --- /dev/null +++ b/apps/web/src/pages/setup/ui/outcome-notice.tsx @@ -0,0 +1,75 @@ +import { cn } from "@freenary/ui/lib/utils"; +import { RiCheckLine, RiErrorWarningLine } from "@remixicon/react"; +import { AnimatePresence, motion } from "motion/react"; + +import { m } from "@/paraglide/messages.js"; +import { remixIcon } from "@/shared/lib/remix-icon"; + +import type { SaveOutcome } from "../model/outcome"; +import { + isFailedOutcome, + outcomeDetail, + outcomeMessage, +} from "../model/outcome"; + +const NOTICE_ICON_SIZE = 16; +const ICON_SWAP = { bounce: 0, duration: 0.3, type: "spring" } as const; +const ICON_HIDDEN = { filter: "blur(4px)", opacity: 0, scale: 0.25 }; +const ICON_SHOWN = { filter: "blur(0px)", opacity: 1, scale: 1 }; +const AlertIcon = remixIcon(RiErrorWarningLine); +const CheckIcon = remixIcon(RiCheckLine); + +interface OutcomeNoticeProps { + outcome: SaveOutcome | undefined; + provider: string; +} + +export const OutcomeNotice = ({ outcome, provider }: OutcomeNoticeProps) => { + if (outcome === undefined) { + return null; + } + + const isFailure = isFailedOutcome(outcome); + const detail = isFailure ? outcomeDetail(outcome) : null; + + return ( +
+

+ + + + + + {outcomeMessage(outcome, provider)} +

+ {detail === null ? null : ( +
+ + {m.setup_show_details()} + +

{detail}

+
+ )} +
+ ); +}; diff --git a/apps/web/src/pages/setup/ui/provider-choice.tsx b/apps/web/src/pages/setup/ui/provider-choice.tsx new file mode 100644 index 00000000..9c5e71c7 --- /dev/null +++ b/apps/web/src/pages/setup/ui/provider-choice.tsx @@ -0,0 +1,105 @@ +import { Button } from "@freenary/ui/components/button"; +import { useIcon } from "@freenary/ui/lib/icon-context"; +import { cn } from "@freenary/ui/lib/utils"; +import { useId } 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 { SetupIntegrationDescriptor } from "./provider-step"; + +const SKIP_VARIANT = "disabled"; + +const skipLast = (variantId: string): number => + variantId === SKIP_VARIANT ? 1 : 0; + +interface ProviderChoiceProps { + chosen: string; + descriptor: SetupIntegrationDescriptor; + isBusy: boolean; + isFirstStep: boolean; + onBack: () => void; + onChoose: (variantId: string) => void; + onContinue: () => void; +} + +export const ProviderChoice = ({ + chosen, + descriptor, + isBusy, + isFirstStep, + onBack, + onChoose, + onContinue, +}: ProviderChoiceProps) => { + const ArrowLeftIcon = useIcon("arrow-left"); + const groupName = useId(); + const titleId = useId(); + + return ( +
+
+ +
+ +
+ {descriptor.variants + .toSorted((first, second) => skipLast(first.id) - skipLast(second.id)) + .map((variant) => { + const isChosen = variant.id === chosen; + + return ( + + ); + })} +
+ +
+ {isFirstStep ? ( + + ) : ( + + )} + +
+
+ ); +}; diff --git a/apps/web/src/pages/setup/ui/provider-credentials.tsx b/apps/web/src/pages/setup/ui/provider-credentials.tsx new file mode 100644 index 00000000..bce50cde --- /dev/null +++ b/apps/web/src/pages/setup/ui/provider-credentials.tsx @@ -0,0 +1,144 @@ +import { Button } from "@freenary/ui/components/button"; +import { Elevated } from "@freenary/ui/lib/elevated"; +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 { variantLabel } from "../model/labels"; +import type { SaveOutcome } from "../model/outcome"; +import { GuideLink } from "./guide-link"; +import { OutcomeNotice } from "./outcome-notice"; +import type { SetupVariantDescriptor } from "./provider-step"; +import { ServerValueRow } from "./server-value-row"; +import type { SetupFieldDescriptor } from "./setup-field"; +import { SetupField } from "./setup-field"; + +const ENVIRONMENT_SOURCE = "environment"; +const SURFACE_RADIUS = "rounded-xl"; +const SURFACE_STEP = 1; +const PROBE_FAILED = "probe-failed"; + +interface ProviderCredentialsProps { + isBusy: boolean; + onBack: () => void; + onEdit: () => void; + onSave: (values: Record, checkFirst: boolean) => void; + outcome: SaveOutcome | undefined; + variant: SetupVariantDescriptor; +} + +const isFromServer = (field: SetupFieldDescriptor): boolean => + field.source === ENVIRONMENT_SOURCE; + +const filledValuesOf = ( + fields: readonly SetupFieldDescriptor[] +): Record => + Object.fromEntries( + fields.flatMap((field) => + field.value === null || isFromServer(field) + ? [] + : [[field.key, field.value]] + ) + ); + +export const ProviderCredentials = ({ + isBusy, + onBack, + onEdit, + onSave, + outcome, + variant, +}: ProviderCredentialsProps) => { + const ArrowLeftIcon = useIcon("arrow-left"); + const [values, setValues] = useState(() => filledValuesOf(variant.fields)); + const provider = variantLabel(variant.id); + + const fromServer = variant.fields.filter(isFromServer); + const editable = variant.fields.filter((field) => !isFromServer(field)); + const required = editable.filter((field) => field.required); + const optional = editable.filter((field) => !field.required); + const checkFailed = outcome?.outcome === PROBE_FAILED; + + const renderField = (field: SetupFieldDescriptor) => ( + { + onEdit(); + setValues((held) => ({ ...held, [key]: next })); + }} + value={values[field.key]} + /> + ); + + return ( +
+
+ + +
+ + {fromServer.length > 0 ? ( + +
+ {fromServer.map((field) => ( + + ))} +
+
+ ) : null} + + {required.length > 0 ? ( +
{required.map(renderField)}
+ ) : null} + + {optional.length > 0 ? ( +
+ + {m.setup_more_options()} + +
+ {optional.map(renderField)} +
+
+ ) : null} + + + +
+ +
+ {checkFailed ? ( + + ) : 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..34cf7d65 --- /dev/null +++ b/apps/web/src/pages/setup/ui/provider-step.tsx @@ -0,0 +1,101 @@ +import { useState } from "react"; + +import type { SaveOutcome } from "../model/outcome"; +import { ProviderChoice } from "./provider-choice"; +import { ProviderCredentials } from "./provider-credentials"; +import type { SetupFieldDescriptor } from "./setup-field"; + +const SKIP_VARIANT = "disabled"; + +export interface SetupVariantDescriptor { + fields: SetupFieldDescriptor[]; + id: string; +} + +export interface SetupIntegrationDescriptor { + configuredBy: string | null; + discriminantKey: string; + discriminantSource: string; + id: string; + selectedVariantId: string; + state: string; + variants: SetupVariantDescriptor[]; +} + +interface ProviderStepProps { + descriptor: SetupIntegrationDescriptor; + isBusy: boolean; + isFirstStep: boolean; + onBack: () => void; + onEdit: () => void; + onSave: ( + variantId: string, + values: Record, + checkFirst: boolean + ) => void; + onSkip: () => void; + outcome: SaveOutcome | undefined; +} + +const defaultChoiceOf = (descriptor: SetupIntegrationDescriptor): string => { + if (descriptor.selectedVariantId !== SKIP_VARIANT) { + return descriptor.selectedVariantId; + } + + const firstProvider = descriptor.variants.find( + (variant) => variant.id !== SKIP_VARIANT + ); + + return firstProvider?.id ?? SKIP_VARIANT; +}; + +export const ProviderStep = ({ + descriptor, + isBusy, + isFirstStep, + onBack, + onEdit, + onSave, + onSkip, + outcome, +}: ProviderStepProps) => { + const [chosen, setChosen] = useState(() => defaultChoiceOf(descriptor)); + const [isConfiguring, setIsConfiguring] = useState(false); + const variant = descriptor.variants.find((entry) => entry.id === chosen); + + if (isConfiguring && variant !== undefined) { + return ( + { + onEdit(); + setIsConfiguring(false); + }} + onEdit={onEdit} + onSave={(values, checkFirst) => onSave(chosen, values, checkFirst)} + outcome={outcome} + variant={variant} + /> + ); + } + + return ( + { + if (chosen === SKIP_VARIANT) { + onSkip(); + + return; + } + + setIsConfiguring(true); + }} + /> + ); +}; diff --git a/apps/web/src/pages/setup/ui/server-value-row.tsx b/apps/web/src/pages/setup/ui/server-value-row.tsx new file mode 100644 index 00000000..bb858c1c --- /dev/null +++ b/apps/web/src/pages/setup/ui/server-value-row.tsx @@ -0,0 +1,33 @@ +import { m } from "@/paraglide/messages.js"; + +import { fieldLabel } from "../model/labels"; +import type { SetupFieldDescriptor } from "./setup-field"; +import { isSecretKind } from "./setup-field"; + +interface ServerValueRowProps { + descriptor: SetupFieldDescriptor; + showsSource: boolean; +} + +export const ServerValueRow = ({ + descriptor, + showsSource, +}: ServerValueRowProps) => { + const shown = isSecretKind(descriptor.kind) ? null : descriptor.value; + + return ( +
+
{fieldLabel(descriptor.key)}
+
+ {shown === null ? null : ( + {shown} + )} + {showsSource ? ( + + {m.setup_set_by_server()} + + ) : null} +
+
+ ); +}; 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..4f12ec46 --- /dev/null +++ b/apps/web/src/pages/setup/ui/setup-field.tsx @@ -0,0 +1,90 @@ +import { Field, 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; +} + +export const isSecretKind = (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 secret = isSecretKind(descriptor.kind); + const secretPlaceholder = + secret && 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) + } + /> + + ); + } + + return ( + + {fieldLabel(descriptor.key)} + {descriptor.kind === "secret-block" ? ( +