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..3690aa65 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,22 @@ 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`), `claimedAt?`, `completedAt?`, `createdAt` | At most one row, for the whole setup lifecycle. `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 runs in the better-auth `session.create.after` hook, at each new session. It inserts the row with `createMany` and `skipDuplicates`. Then one `updateMany` on `instance_setup`, matched on `claimedAt: null`, runs inside the transaction that sets `User.role`. That conditional update is the lock. A second session matches no row, so two accounts cannot both become the operator. + +The migration that creates the table sets `claimedAt` and `completedAt` on an instance that already has users. It gives the role to the oldest user with `emailVerified`, or to the oldest user when none is verified. + ## 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,9 +221,32 @@ 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 first account that signed in to the instance. It opens the setup screen, and it reads no financial row of another user | + +Two code paths set `OPERATOR`: one `user.update` inside the claim transaction, and the backfill `UPDATE` in the migration `20260922120000_instance_configuration`. The SQL in [Troubleshooting](/docs/self-hosting/troubleshooting#the-wrong-account-became-the-operator) can also move the role to another account. 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`. +`BudgetLineKind` was another enum 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`. ### Free-text vocabularies diff --git a/apps/fumadocs/content/docs/next/quickstart.mdx b/apps/fumadocs/content/docs/next/quickstart.mdx index abff5f95..160f74e3 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. @@ -137,9 +128,21 @@ Open `http://localhost:3001` in a browser. The screen reads `Welcome to Freenary 2. Write a name of 2 characters or more, and a password of 12 characters or more. 3. Press `Create account`. -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. +With no email provider configured, Freenary signs you in at once and opens `/setup`. 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, then opens `/setup`. + + + + + +### Add the credentials + +The first account to sign in becomes the operator of this instance. Freenary opens `/setup` for it, and no second account can take the role. + +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. -Freenary has no admin account and no owner role. The first account is an ordinary user. +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 onboarding wizard opens next. + +[The setup screen](/docs/self-hosting/setup) holds each card, each check and the last step in full. @@ -149,6 +152,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 +174,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 dafde414..1e109e4c 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..c2e0f1f9 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,9 +180,19 @@ 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. An instance on a public origin is open to anybody who finds it. Create that account in the same sitting as the install. + + + + -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`. +### Open the setup screen + +The account you made is the operator of the instance, because it signed in first. Freenary opens `/setup` for it. [The setup screen](/docs/self-hosting/setup#the-first-account-becomes-the-operator) tells more. + +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. + +After `Finish setup`, the onboarding wizard opens, and it 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`. [First steps](/docs/guides/first-steps) covers what the operator does next. @@ -203,6 +206,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 +132,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 +148,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. +- [ ] Your own account is the operator, 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..cec3b6d2 --- /dev/null +++ b/apps/fumadocs/content/docs/next/self-hosting/setup.mdx @@ -0,0 +1,136 @@ +--- +title: The setup screen +description: Become the operator of 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`. + +`Save and continue` tests each credential against the real service, then stores it. + +## 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. + +## The first account becomes the operator + +The first account that signs in to a new instance becomes its operator. Only the operator can use the setup screen. Every account after it is an ordinary user, and `/setup` tells that user to ask the operator. + +No screen and no endpoint gives the role to a second account. An instance on a public origin is open to anybody who finds it. Create the first account immediately after the first start. + +An instance with accounts from an earlier release gives the role to its oldest account with a confirmed email address. When no address is confirmed, the oldest account gets the role. That instance also counts as set up, so Freenary sends no account to `/setup`. The operator can still open `/setup` to change a provider. + +## Walk the steps + +The wizard runs 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 one row for each integration. A check mark and the provider name show a configured integration. Otherwise the row shows `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`. The row name is bound to the sealed value, 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. + +The server checks the stored values at startup. When they fail that check, the server ignores all of them and uses the environment alone. The setup screen then shows `Configuration problem` and the reason. Correct the value, save it, then press `Finish setup`. + + + + + + + + + + + + diff --git a/apps/fumadocs/content/docs/next/self-hosting/troubleshooting.mdx b/apps/fumadocs/content/docs/next/self-hosting/troubleshooting.mdx index 481ab474..a6ed222f 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: @@ -361,6 +381,22 @@ The chat route answers with a bare machine code on every failure. [Assistant](/docs/self-hosting/assistant) covers the endpoints and what each question sends out. +## The wrong account became the operator + +**What you see.** Your account opens `/setup` and reads `Only the operator can set up this instance`. + +**Why.** Another account signed in to the instance before yours. The first account to sign in becomes the operator, and no screen moves the role. + +An instance with accounts from an earlier release got its operator from the upgrade. The oldest account with a confirmed email address took the role, or the oldest account when none is confirmed. + +**The fix.** When you made both accounts, move the role to your account. Put your email address in place of `you@example.com`: + +```bash +docker compose exec -T postgres psql -U postgres -d freenary -c "UPDATE \"user\" SET role = CASE WHEN email = 'you@example.com' THEN 'OPERATOR'::\"UserRole\" ELSE 'MEMBER'::\"UserRole\" END;" +``` + +When a stranger made the first account, treat the instance as compromised. Stop it, remove the database volume, and install again before anybody else can reach it. [Security](/docs/self-hosting/security#the-operator) tells more. + ## Every caller shares one rate-limit bucket **What you see.** Users reach the rate-limit error after one try each. The problem follows the instance, and not one person. 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 f0f2f342..d38fdd3c 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 { readSetupState } 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 } 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 ${env.BETTER_AUTH_URL}`); }); + +const setupState = await Effect.runPromise(readSetupState()); + +if (!setupState.claimed) { + console.log( + `This instance has no operator yet. The first account to sign in at ${env.CORS_ORIGIN} becomes the operator.` + ); +} diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index c26b6a7c..736da158 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,75 @@ } } ], + "setup_applying_description": "This page reconnects on its own.", + "setup_applying_title": "Applying your settings…", + "setup_back": "Back", + "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_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_ready": "{provider} is ready", + "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_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_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_load_failed_description": "Freenary could not read the setup state of this instance.", + "setup_load_failed_title": "The setup screen did not load", + "setup_loading": "Loading the setup screen", + "setup_more_options": "More options", + "setup_not_operator_description": "The first account on this instance is its operator. Ask the operator to change the configuration.", + "setup_not_operator_title": "Only the operator can set up this instance", + "setup_progress_label": "Setup progress", + "setup_save_and_continue": "Save and continue", + "setup_save_anyway": "Save anyway", + "setup_saved_unchecked": "Saved without a connection test.", + "setup_secret_unchanged_hint": "Saved. Leave empty to keep it.", + "setup_set_by_server": "Set by your server", + "setup_show_details": "Show details", + "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_email": "Email", + "setup_step_finish": "Done", + "setup_test_connection": "Test connection", + "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_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..27f33e57 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,75 @@ } } ], + "setup_applying_description": "Cette page se reconnecte d'elle-même.", + "setup_applying_title": "Application de vos réglages…", + "setup_back": "Retour", + "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_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_ready": "{provider} est prêt", + "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_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_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_load_failed_description": "Freenary n’a pas pu lire l’état de configuration de cette instance.", + "setup_load_failed_title": "L’écran de configuration ne s’est pas chargé", + "setup_loading": "Chargement de l'écran de configuration", + "setup_more_options": "Plus d'options", + "setup_not_operator_description": "Le premier compte de cette instance en est l’opérateur. Demandez-lui de modifier la configuration.", + "setup_not_operator_title": "Seul l’opérateur peut configurer cette instance", + "setup_progress_label": "Progression de la configuration", + "setup_save_and_continue": "Enregistrer et continuer", + "setup_save_anyway": "Enregistrer quand même", + "setup_saved_unchecked": "Enregistré sans test de connexion.", + "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_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_email": "Courriel", + "setup_step_finish": "Fin", + "setup_test_connection": "Tester la connexion", + "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_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..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/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..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..5e063366 --- /dev/null +++ b/apps/web/src/pages/setup/model/labels.ts @@ -0,0 +1,90 @@ +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, + 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 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); + +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 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..4d00798d --- /dev/null +++ b/apps/web/src/pages/setup/model/use-setup.ts @@ -0,0 +1,158 @@ +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"; + +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 isOperator = status.data?.isOperator ?? false; + + const configuration = useQuery( + orpc.instance.describe.queryOptions({ + enabled: isOperator && restartPhase === "idle", + retry: false, + }) + ); + + const loadFailed = status.isError || configuration.isError; + + const retryLoad = async () => { + await (status.isError ? status.refetch() : configuration.refetch()); + }; + + const integrationSteps = + configuration.data?.integrations.map((integration) => integration.id) ?? []; + + const steps = [...integrationSteps, FINISH_STEP]; + + const stepId = chosenStep ?? steps[0] ?? FINISH_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 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, + complete, + configuration, + direction, + handleBack, + handleNext, + handleSignOut, + isOperator, + isPending: status.isPending || (isOperator && configuration.isPending), + loadFailed, + reconnect, + restartPhase, + retryLoad, + save, + stepId, + stepIndex, + steps, + }; +}; 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/fault-notice.tsx b/apps/web/src/pages/setup/ui/fault-notice.tsx new file mode 100644 index 00000000..c2c061cc --- /dev/null +++ b/apps/web/src/pages/setup/ui/fault-notice.tsx @@ -0,0 +1,30 @@ +import { RiErrorWarningLine } from "@remixicon/react"; + +import { m } from "@/paraglide/messages.js"; +import { remixIcon } from "@/shared/lib/remix-icon"; + +const NOTICE_ICON_SIZE = 16; +const AlertIcon = remixIcon(RiErrorWarningLine); + +interface FaultNoticeProps { + detail: string; +} + +export const FaultNotice = ({ detail }: FaultNoticeProps) => ( +
+
+); 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..b152edf3 --- /dev/null +++ b/apps/web/src/pages/setup/ui/finish-step.tsx @@ -0,0 +1,120 @@ +import { Button } from "@freenary/ui/components/button"; +import { Elevated } from "@freenary/ui/lib/elevated"; +import { useIcon } from "@freenary/ui/lib/icon-context"; +import { RiCheckLine, RiErrorWarningLine } 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 { integrationTitle, stateLabel, variantLabel } from "../model/labels"; +import type { SetupIntegrationDescriptor } from "./provider-step"; + +const CONFIGURED_STATE = "configured"; +const DISABLED_STATE = "disabled"; +const SURFACE_RADIUS = "rounded-xl"; +const SURFACE_STEP = 1; +const CHECK_ICON_SIZE = 14; +const ALERT_ICON_SIZE = 20; +const CheckIcon = remixIcon(RiCheckLine); +const AlertIcon = remixIcon(RiErrorWarningLine); + +interface StateMarkProps { + state: string; +} + +const StateMark = ({ state }: StateMarkProps) => { + if (state === CONFIGURED_STATE) { + return ( + + ); + } + + if (state === DISABLED_STATE) { + return ( +