feat: require owner login and add onboarding
This commit is contained in:
@@ -9,3 +9,6 @@ TWITTER_LITE_MASTODON_ORIGINS=https://fedi.yutakobayashi.com
|
|||||||
# TWITTER_LITE_CODEX_URL=ws://127.0.0.1:4500
|
# TWITTER_LITE_CODEX_URL=ws://127.0.0.1:4500
|
||||||
# TWITTER_LITE_CODEX_MODEL=gpt-6-astra
|
# TWITTER_LITE_CODEX_MODEL=gpt-6-astra
|
||||||
# TWITTER_LITE_REPORT_ROOT=/absolute/path/to/twitter-lite/.data/research
|
# TWITTER_LITE_REPORT_ROOT=/absolute/path/to/twitter-lite/.data/research
|
||||||
|
|
||||||
|
# Optional initial owner setup code (32+ characters). Otherwise generated beside DB.
|
||||||
|
# WORKSPACE_SETUP_TOKEN=
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ node_modules/
|
|||||||
playwright-report/
|
playwright-report/
|
||||||
test-results/
|
test-results/
|
||||||
.data/
|
.data/
|
||||||
|
*.setup-token
|
||||||
|
|||||||
@@ -63,6 +63,13 @@ or change direction.
|
|||||||
Original-post links open their source site; SNS reply threads are not rendered
|
Original-post links open their source site; SNS reply threads are not rendered
|
||||||
inside the app. Bluesky, Threads, and Nostr connectors are not implemented.
|
inside the app. Bluesky, Threads, and Nostr connectors are not implemented.
|
||||||
|
|
||||||
|
## Login and onboarding
|
||||||
|
|
||||||
|
The workspace requires a single owner account with email and password. First
|
||||||
|
use opens account setup, followed by a short onboarding. Run
|
||||||
|
`nix develop -c pnpm account:setup` on the server to obtain the private setup
|
||||||
|
code. See [owner login](docs/login.md) for configuration, sessions, and limits.
|
||||||
|
|
||||||
## Requirements and setup
|
## Requirements and setup
|
||||||
|
|
||||||
Use Nix, or Node.js `>=22.12.0` with pnpm `11.9.0`. The committed `.npmrc`
|
Use Nix, or Node.js `>=22.12.0` with pnpm `11.9.0`. The committed `.npmrc`
|
||||||
@@ -107,6 +114,7 @@ nix develop -c pnpm test:e2e
|
|||||||
nix develop -c pnpm test:live
|
nix develop -c pnpm test:live
|
||||||
nix develop -c pnpm build
|
nix develop -c pnpm build
|
||||||
nix develop -c pnpm start
|
nix develop -c pnpm start
|
||||||
|
nix develop -c pnpm account:setup
|
||||||
nix develop -c pnpm db:generate
|
nix develop -c pnpm db:generate
|
||||||
nix develop -c pnpm codex:serve
|
nix develop -c pnpm codex:serve
|
||||||
```
|
```
|
||||||
@@ -271,10 +279,10 @@ to localhost does not supply the required identity. Playwright supplies an
|
|||||||
explicit fixture identity to its isolated test server.
|
explicit fixture identity to its isolated test server.
|
||||||
|
|
||||||
For a private, tailnet-only reverse proxy such as Traefik, explicitly set
|
For a private, tailnet-only reverse proxy such as Traefik, explicitly set
|
||||||
`TWITTER_LITE_AUTH_MODE=none` to disable application identity checks. This mode
|
`TWITTER_LITE_AUTH_MODE=none` to disable the Tailscale identity check. This mode
|
||||||
does not require `TWITTER_LITE_ALLOWED_LOGIN` or Tailscale identity headers;
|
does not require `TWITTER_LITE_ALLOWED_LOGIN` or Tailscale identity headers.
|
||||||
anyone who can reach that proxy can use the app and its connected accounts and
|
App login remains mandatory, including access to connected accounts and Codex.
|
||||||
Codex. Bind the backend to loopback or its Tailscale address and restrict proxy
|
Bind the backend to loopback or its Tailscale address and restrict proxy
|
||||||
access to the tailnet.
|
access to the tailnet.
|
||||||
Both modes require the exact configured `Origin` for state-changing requests,
|
Both modes require the exact configured `Origin` for state-changing requests,
|
||||||
including chat and deck mutations. Missing origin or an unknown auth mode
|
including chat and deck mutations. Missing origin or an unknown auth mode
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Owner login and onboarding
|
||||||
|
|
||||||
|
This is a single-owner workspace. App login is mandatory for all workspace
|
||||||
|
pages, server functions, and research streams. The existing Tailscale identity
|
||||||
|
check is an optional outer boundary; `TWITTER_LITE_AUTH_MODE=none` disables only
|
||||||
|
that outer check, not app login. Origin checks still protect mutations.
|
||||||
|
|
||||||
|
## First use
|
||||||
|
|
||||||
|
Start the server with its usual database and public-origin configuration, then
|
||||||
|
run this on the server using the same database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix develop -c pnpm account:setup
|
||||||
|
```
|
||||||
|
|
||||||
|
The Nix package also provides `twitter-lite-setup`; run it with the service
|
||||||
|
database path and filesystem permissions.
|
||||||
|
|
||||||
|
The command loads `.env.local` when present. An exported `TWITTER_LITE_DB_PATH`
|
||||||
|
takes precedence. It prints a setup code for `/setup`; keep that code private.
|
||||||
|
The code is created in `<database path>.setup-token` with mode 0600, or supplied
|
||||||
|
through `WORKSPACE_SETUP_TOKEN` (at least 32 characters). It is never returned
|
||||||
|
by the app's public API. The owner account can be created only once, including
|
||||||
|
when two setup requests arrive together. The code cannot register another
|
||||||
|
account or reset an existing password after setup.
|
||||||
|
|
||||||
|
Visit the app, enter the setup code, your name, email, and a password of 15–128
|
||||||
|
characters. The email is a login identifier; this prototype does not verify it
|
||||||
|
or send email. Choose your display name and finish the short onboarding to
|
||||||
|
open Home. Onboarding completion and the name persist in SQLite. Home greets
|
||||||
|
you by that name.
|
||||||
|
|
||||||
|
## Sessions
|
||||||
|
|
||||||
|
Passwords use salted scrypt hashes (N=2^17, r=8, p=1). Session cookies are
|
||||||
|
HttpOnly, SameSite=Lax, host-only, and Secure for an HTTPS public origin.
|
||||||
|
Only token hashes are stored in SQLite; sessions expire after 30 days.
|
||||||
|
Setup and login share a persistent limit of 10 attempts per 15 minutes;
|
||||||
|
successful authentication clears that limit.
|
||||||
|
|
||||||
|
Open the avatar menu and choose **Sign out** to revoke the current session.
|
||||||
|
Open research streams stop sending updates when the session is revoked or
|
||||||
|
expires, with an idle check every 15 seconds. Other logged-in devices retain
|
||||||
|
their own sessions. Authenticated responses are not cached.
|
||||||
|
|
||||||
|
Account registration is closed once the owner exists. Multiple users,
|
||||||
|
email-based password recovery, and account management are not implemented.
|
||||||
|
Back up the SQLite database as described in [storage](storage-and-oauth.md).
|
||||||
|
Home tasks, messages, and journal entries remain session-local prototypes;
|
||||||
|
onboarding does not make those records persistent.
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
- `src/features/auth/auth.server.ts`: owner, password hashing, throttling, sessions.
|
||||||
|
- `src/features/auth/gate.server.ts` and `src/start.ts`: request-level protection.
|
||||||
|
- `/api/auth`: session state, setup, sign-in, onboarding, and sign-out.
|
||||||
|
- Root route guard: client navigation and onboarding redirects.
|
||||||
|
- `/setup`, `/login`, `/onboarding`: English forms using the existing UI system.
|
||||||
|
|
||||||
|
Password and session handling follow the relevant
|
||||||
|
[OWASP password storage](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html)
|
||||||
|
and [session management](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html)
|
||||||
|
guidance. Tests cover invalid credentials, bootstrap ownership, expiry,
|
||||||
|
revocation, request protection, and onboarding persistence.
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
# Shared storage and Mastodon OAuth
|
# Shared storage and Mastodon OAuth
|
||||||
|
|
||||||
Personal Workspace runs as a single personal server behind Tailscale Serve. The
|
Personal Workspace runs as a single personal server behind Tailscale Serve. The
|
||||||
backend binds to loopback and accepts only the configured Tailscale login.
|
backend binds to loopback. The configured Tailscale identity is an outer access
|
||||||
|
check; a separate [owner login](login.md) is required in every deployment mode.
|
||||||
Browser requests that change state must have the configured Origin. OAuth
|
Browser requests that change state must have the configured Origin. OAuth
|
||||||
callbacks also pass the owner check; the browser must be able to reach the
|
callbacks also pass the owner check; the browser must be able to reach the
|
||||||
tailnet HTTPS address after Mastodon authorization.
|
tailnet HTTPS address after Mastodon authorization.
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
CREATE TABLE `auth_sessions` (
|
||||||
|
`token_hash` text PRIMARY KEY NOT NULL,
|
||||||
|
`owner_id` integer NOT NULL,
|
||||||
|
`expires_at` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`owner_id`) REFERENCES `workspace_owner`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `auth_throttle` (
|
||||||
|
`id` integer PRIMARY KEY NOT NULL,
|
||||||
|
`attempts` integer NOT NULL,
|
||||||
|
`reset_at` integer NOT NULL,
|
||||||
|
CONSTRAINT "auth_throttle_singleton" CHECK("auth_throttle"."id" = 1)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `workspace_owner` (
|
||||||
|
`id` integer PRIMARY KEY NOT NULL,
|
||||||
|
`email` text NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`password_hash` text NOT NULL,
|
||||||
|
`onboarding_completed_at` integer,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
CONSTRAINT "workspace_owner_singleton" CHECK("workspace_owner"."id" = 1)
|
||||||
|
);
|
||||||
@@ -0,0 +1,696 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "52f68a9d-b9da-4739-9635-b0b5e3e8040b",
|
||||||
|
"prevId": "d021bc5b-2422-467a-ab92-2493da6e642a",
|
||||||
|
"tables": {
|
||||||
|
"auth_sessions": {
|
||||||
|
"name": "auth_sessions",
|
||||||
|
"columns": {
|
||||||
|
"token_hash": {
|
||||||
|
"name": "token_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"owner_id": {
|
||||||
|
"name": "owner_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"auth_sessions_owner_id_workspace_owner_id_fk": {
|
||||||
|
"name": "auth_sessions_owner_id_workspace_owner_id_fk",
|
||||||
|
"tableFrom": "auth_sessions",
|
||||||
|
"tableTo": "workspace_owner",
|
||||||
|
"columnsFrom": ["owner_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"auth_throttle": {
|
||||||
|
"name": "auth_throttle",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"attempts": {
|
||||||
|
"name": "attempts",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"reset_at": {
|
||||||
|
"name": "reset_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {
|
||||||
|
"auth_throttle_singleton": {
|
||||||
|
"name": "auth_throttle_singleton",
|
||||||
|
"value": "\"auth_throttle\".\"id\" = 1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"connection_credentials": {
|
||||||
|
"name": "connection_credentials",
|
||||||
|
"columns": {
|
||||||
|
"connection_id": {
|
||||||
|
"name": "connection_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"encrypted_token": {
|
||||||
|
"name": "encrypted_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"app_id": {
|
||||||
|
"name": "app_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"connection_credentials_connection_id_connections_id_fk": {
|
||||||
|
"name": "connection_credentials_connection_id_connections_id_fk",
|
||||||
|
"tableFrom": "connection_credentials",
|
||||||
|
"tableTo": "connections",
|
||||||
|
"columnsFrom": ["connection_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"connection_credentials_app_id_oauth_apps_id_fk": {
|
||||||
|
"name": "connection_credentials_app_id_oauth_apps_id_fk",
|
||||||
|
"tableFrom": "connection_credentials",
|
||||||
|
"tableTo": "oauth_apps",
|
||||||
|
"columnsFrom": ["app_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "restrict",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"connections": {
|
||||||
|
"name": "connections",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"platform": {
|
||||||
|
"name": "platform",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"origin": {
|
||||||
|
"name": "origin",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"account_id": {
|
||||||
|
"name": "account_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"relay_profile": {
|
||||||
|
"name": "relay_profile",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"display_name": {
|
||||||
|
"name": "display_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"connections_account": {
|
||||||
|
"name": "connections_account",
|
||||||
|
"columns": ["platform", "origin", "account_id"],
|
||||||
|
"isUnique": true
|
||||||
|
},
|
||||||
|
"connections_relay_profile": {
|
||||||
|
"name": "connections_relay_profile",
|
||||||
|
"columns": ["origin", "relay_profile"],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"deck_columns": {
|
||||||
|
"name": "deck_columns",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"deck_id": {
|
||||||
|
"name": "deck_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"position": {
|
||||||
|
"name": "position",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"connection_id": {
|
||||||
|
"name": "connection_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"name": "source",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"deck_columns_position": {
|
||||||
|
"name": "deck_columns_position",
|
||||||
|
"columns": ["deck_id", "position"],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"deck_columns_deck_id_decks_id_fk": {
|
||||||
|
"name": "deck_columns_deck_id_decks_id_fk",
|
||||||
|
"tableFrom": "deck_columns",
|
||||||
|
"tableTo": "decks",
|
||||||
|
"columnsFrom": ["deck_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"deck_columns_connection_id_connections_id_fk": {
|
||||||
|
"name": "deck_columns_connection_id_connections_id_fk",
|
||||||
|
"tableFrom": "deck_columns",
|
||||||
|
"tableTo": "connections",
|
||||||
|
"columnsFrom": ["connection_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "restrict",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"deck_columns_deck_id_id_pk": {
|
||||||
|
"columns": ["deck_id", "id"],
|
||||||
|
"name": "deck_columns_deck_id_id_pk"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {
|
||||||
|
"deck_columns_valid_position": {
|
||||||
|
"name": "deck_columns_valid_position",
|
||||||
|
"value": "\"deck_columns\".\"position\" >= 0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"decks": {
|
||||||
|
"name": "decks",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"revision": {
|
||||||
|
"name": "revision",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {
|
||||||
|
"decks_positive_revision": {
|
||||||
|
"name": "decks_positive_revision",
|
||||||
|
"value": "\"decks\".\"revision\" >= 1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"legacy_imports": {
|
||||||
|
"name": "legacy_imports",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"payload_hash": {
|
||||||
|
"name": "payload_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"deck_ids": {
|
||||||
|
"name": "deck_ids",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"oauth_apps": {
|
||||||
|
"name": "oauth_apps",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"origin": {
|
||||||
|
"name": "origin",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"redirect_uri": {
|
||||||
|
"name": "redirect_uri",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"scopes": {
|
||||||
|
"name": "scopes",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"client_id": {
|
||||||
|
"name": "client_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"encrypted_client_secret": {
|
||||||
|
"name": "encrypted_client_secret",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"oauth_apps_configuration": {
|
||||||
|
"name": "oauth_apps_configuration",
|
||||||
|
"columns": ["origin", "redirect_uri", "scopes"],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"oauth_attempts": {
|
||||||
|
"name": "oauth_attempts",
|
||||||
|
"columns": {
|
||||||
|
"state_hash": {
|
||||||
|
"name": "state_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"browser_hash": {
|
||||||
|
"name": "browser_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"app_id": {
|
||||||
|
"name": "app_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"encrypted_verifier": {
|
||||||
|
"name": "encrypted_verifier",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"connection_id": {
|
||||||
|
"name": "connection_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"consumed_at": {
|
||||||
|
"name": "consumed_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"oauth_attempts_app_id_oauth_apps_id_fk": {
|
||||||
|
"name": "oauth_attempts_app_id_oauth_apps_id_fk",
|
||||||
|
"tableFrom": "oauth_attempts",
|
||||||
|
"tableTo": "oauth_apps",
|
||||||
|
"columnsFrom": ["app_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"oauth_attempts_connection_id_connections_id_fk": {
|
||||||
|
"name": "oauth_attempts_connection_id_connections_id_fk",
|
||||||
|
"tableFrom": "oauth_attempts",
|
||||||
|
"tableTo": "connections",
|
||||||
|
"columnsFrom": ["connection_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"research_sessions": {
|
||||||
|
"name": "research_sessions",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"snapshot": {
|
||||||
|
"name": "snapshot",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"research_state": {
|
||||||
|
"name": "research_state",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"active_session_id": {
|
||||||
|
"name": "active_session_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"research_state_active_session_id_research_sessions_id_fk": {
|
||||||
|
"name": "research_state_active_session_id_research_sessions_id_fk",
|
||||||
|
"tableFrom": "research_state",
|
||||||
|
"tableTo": "research_sessions",
|
||||||
|
"columnsFrom": ["active_session_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "set null",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {
|
||||||
|
"research_state_singleton": {
|
||||||
|
"name": "research_state_singleton",
|
||||||
|
"value": "\"research_state\".\"id\" = 1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"workspace_owner": {
|
||||||
|
"name": "workspace_owner",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"onboarding_completed_at": {
|
||||||
|
"name": "onboarding_completed_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {
|
||||||
|
"workspace_owner_singleton": {
|
||||||
|
"name": "workspace_owner_singleton",
|
||||||
|
"value": "\"workspace_owner\".\"id\" = 1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,13 @@
|
|||||||
"when": 1790242505709,
|
"when": 1790242505709,
|
||||||
"tag": "0003_romantic_scrambler",
|
"tag": "0003_romantic_scrambler",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1790589494499,
|
||||||
|
"tag": "0004_good_natasha_romanoff",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,63 @@
|
|||||||
import { basename, dirname, isAbsolute } from 'node:path'
|
import { basename, dirname, isAbsolute } from 'node:path'
|
||||||
import AxeBuilder from '@axe-core/playwright'
|
import AxeBuilder from '@axe-core/playwright'
|
||||||
import { test as base } from '@playwright/test'
|
import { test as base } from '@playwright/test'
|
||||||
|
import {
|
||||||
|
completeOnboarding,
|
||||||
|
createOwner,
|
||||||
|
readAuthState,
|
||||||
|
signIn,
|
||||||
|
} from '../src/features/auth/auth.server'
|
||||||
import { openDatabase } from '../src/features/storage/database.server'
|
import { openDatabase } from '../src/features/storage/database.server'
|
||||||
|
|
||||||
export const test = base.extend<{
|
export const test = base.extend<{
|
||||||
a11y: () => AxeBuilder
|
a11y: () => AxeBuilder
|
||||||
resetDecks: undefined
|
resetDecks: undefined
|
||||||
|
sessionToken: string
|
||||||
}>({
|
}>({
|
||||||
|
// biome-ignore lint/correctness/noEmptyPattern: Playwright requires destructured fixture arguments.
|
||||||
|
sessionToken: async ({}, use) => {
|
||||||
|
const path = process.env.TWITTER_LITE_E2E_DB_PATH
|
||||||
|
if (!path || !basename(dirname(path)).startsWith('twitter-lite-e2e-'))
|
||||||
|
throw new Error('Isolated E2E database is required.')
|
||||||
|
const database = openDatabase(path)
|
||||||
|
const credentials = {
|
||||||
|
email: '[email protected]',
|
||||||
|
password: 'E2E-only-passphrase-2026',
|
||||||
|
}
|
||||||
|
process.env.WORKSPACE_SETUP_TOKEN =
|
||||||
|
'isolated-e2e-setup-token-not-for-production'
|
||||||
|
const result = readAuthState(database).needsSetup
|
||||||
|
? await createOwner(
|
||||||
|
{
|
||||||
|
...credentials,
|
||||||
|
name: 'Yuta',
|
||||||
|
setupToken: process.env.WORKSPACE_SETUP_TOKEN,
|
||||||
|
},
|
||||||
|
database,
|
||||||
|
)
|
||||||
|
: await signIn(credentials, database)
|
||||||
|
if (!result.owner.onboardingCompletedAt)
|
||||||
|
completeOnboarding(result.sessionToken, { name: 'Yuta' }, database)
|
||||||
|
database.$client.close()
|
||||||
|
await use(result.sessionToken)
|
||||||
|
},
|
||||||
|
storageState: async ({ sessionToken }, use) => {
|
||||||
|
await use({
|
||||||
|
cookies: [
|
||||||
|
{
|
||||||
|
name: 'workspace_session',
|
||||||
|
value: sessionToken,
|
||||||
|
domain: '127.0.0.1',
|
||||||
|
path: '/',
|
||||||
|
expires: Math.floor(Date.now() / 1000) + 3600,
|
||||||
|
httpOnly: true,
|
||||||
|
secure: false,
|
||||||
|
sameSite: 'Lax',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
origins: [],
|
||||||
|
})
|
||||||
|
},
|
||||||
resetDecks: [
|
resetDecks: [
|
||||||
// biome-ignore lint/correctness/noEmptyPattern: Playwright requires destructured fixture arguments.
|
// biome-ignore lint/correctness/noEmptyPattern: Playwright requires destructured fixture arguments.
|
||||||
async ({}, use) => {
|
async ({}, use) => {
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { openDatabase } from '../../src/features/storage/database.server'
|
||||||
|
import { expect, test } from '../fixtures'
|
||||||
|
|
||||||
|
const origin = 'http://127.0.0.1:4173'
|
||||||
|
|
||||||
|
test('creates the owner once and enters onboarding with a real session', async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
a11y,
|
||||||
|
}) => {
|
||||||
|
const db = openDatabase(process.env.TWITTER_LITE_E2E_DB_PATH ?? '')
|
||||||
|
db.$client.exec('DELETE FROM workspace_owner; DELETE FROM auth_throttle;')
|
||||||
|
db.$client.close()
|
||||||
|
await context.clearCookies()
|
||||||
|
await page.goto('/', { waitUntil: 'networkidle' })
|
||||||
|
await expect(page).toHaveURL(/\/setup$/)
|
||||||
|
expect((await a11y().analyze()).violations).toEqual([])
|
||||||
|
await page.getByLabel('Your name', { exact: true }).fill('Yuta')
|
||||||
|
await page.getByLabel('Email address').fill('[email protected]')
|
||||||
|
await page
|
||||||
|
.getByLabel('Password', { exact: true })
|
||||||
|
.fill('E2E-only-passphrase-2026')
|
||||||
|
await page
|
||||||
|
.getByLabel('Confirm password', { exact: true })
|
||||||
|
.fill('E2E-only-passphrase-2026')
|
||||||
|
await page
|
||||||
|
.getByLabel('Setup code')
|
||||||
|
.fill('isolated-e2e-setup-token-not-for-production')
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'Create account', exact: true })
|
||||||
|
.click()
|
||||||
|
await expect(page).toHaveURL(/\/onboarding$/)
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
await page.getByRole('button', { name: 'Continue', exact: true }).click()
|
||||||
|
await page.getByRole('button', { name: 'Open workspace' }).click()
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: 'Good morning, Yuta' }),
|
||||||
|
).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('requires login for documents, server functions, and live updates', async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
const serverRequest = page.waitForRequest((request) =>
|
||||||
|
request.url().includes('/_serverFn/'),
|
||||||
|
)
|
||||||
|
await page.goto('/deck')
|
||||||
|
const serverUrl = (await serverRequest).url()
|
||||||
|
await context.clearCookies()
|
||||||
|
for (const path of ['/api/research/events', serverUrl]) {
|
||||||
|
const result = await context.request.get(path, {
|
||||||
|
headers: { Origin: origin, 'Sec-Fetch-Site': 'same-origin' },
|
||||||
|
})
|
||||||
|
expect(result.status()).toBe(401)
|
||||||
|
}
|
||||||
|
await page.goto('/journal')
|
||||||
|
await expect(page).toHaveURL(/\/login$/)
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: 'Welcome back.' }),
|
||||||
|
).toBeVisible()
|
||||||
|
await expect(
|
||||||
|
page.getByRole('link', { name: 'Journal', exact: true }),
|
||||||
|
).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('signs in and revokes the session on sign out', async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
a11y,
|
||||||
|
}) => {
|
||||||
|
await context.clearCookies()
|
||||||
|
await page.goto('/login', { waitUntil: 'networkidle' })
|
||||||
|
expect((await a11y().analyze()).violations).toEqual([])
|
||||||
|
await page.getByLabel('Email address').fill('[email protected]')
|
||||||
|
await page
|
||||||
|
.getByLabel('Password', { exact: true })
|
||||||
|
.fill('Wrong-passphrase-2026')
|
||||||
|
await page.getByRole('button', { name: 'Sign in', exact: true }).click()
|
||||||
|
await expect(page.getByRole('alert')).toContainText(
|
||||||
|
'Invalid email or password.',
|
||||||
|
)
|
||||||
|
await page
|
||||||
|
.getByLabel('Password', { exact: true })
|
||||||
|
.fill('E2E-only-passphrase-2026')
|
||||||
|
await page.getByRole('button', { name: 'Sign in', exact: true }).click()
|
||||||
|
await expect(page).toHaveURL(`${origin}/`)
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
const token = (await context.cookies()).find(
|
||||||
|
(cookie) => cookie.name === 'workspace_session',
|
||||||
|
)
|
||||||
|
expect(token?.httpOnly).toBe(true)
|
||||||
|
expect(token?.sameSite).toBe('Lax')
|
||||||
|
await page.getByRole('button', { name: 'Manage connected accounts' }).click()
|
||||||
|
await page.getByRole('button', { name: 'Sign out', exact: true }).click()
|
||||||
|
await expect(page).toHaveURL(/\/login$/)
|
||||||
|
const replay = await context.request.get('/api/auth', {
|
||||||
|
headers: { Cookie: `workspace_session=${token?.value}` },
|
||||||
|
})
|
||||||
|
expect((await replay.json()).owner).toBeNull()
|
||||||
|
await page.goBack()
|
||||||
|
await expect(
|
||||||
|
page.getByRole('link', { name: 'Home', exact: true }),
|
||||||
|
).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('requires onboarding and remembers its completion and name', async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
a11y,
|
||||||
|
}) => {
|
||||||
|
const db = openDatabase(process.env.TWITTER_LITE_E2E_DB_PATH ?? '')
|
||||||
|
db.$client
|
||||||
|
.prepare(
|
||||||
|
'UPDATE workspace_owner SET onboarding_completed_at = NULL WHERE id = 1',
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
try {
|
||||||
|
expect((await request.get('/api/research/events')).status()).toBe(403)
|
||||||
|
await page.goto('/', { waitUntil: 'networkidle' })
|
||||||
|
await expect(page).toHaveURL(/\/onboarding$/)
|
||||||
|
expect((await a11y().analyze()).violations).toEqual([])
|
||||||
|
await page.getByLabel('What should we call you?').fill('Yuta Test')
|
||||||
|
await page.getByRole('button', { name: 'Continue', exact: true }).click()
|
||||||
|
await page.getByRole('button', { name: 'Open workspace' }).click()
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: 'Good morning, Yuta Test' }),
|
||||||
|
).toBeVisible()
|
||||||
|
await page.reload()
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: 'Good morning, Yuta Test' }),
|
||||||
|
).toBeVisible()
|
||||||
|
await page.goto('/onboarding')
|
||||||
|
await expect(page).toHaveURL(`${origin}/`)
|
||||||
|
} finally {
|
||||||
|
db.$client
|
||||||
|
.prepare(
|
||||||
|
'UPDATE workspace_owner SET name = ?, onboarding_completed_at = ? WHERE id = 1',
|
||||||
|
)
|
||||||
|
.run('Yuta', Date.now())
|
||||||
|
db.$client.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects cross-origin login and further account registration', async ({
|
||||||
|
request,
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await request.post('/api/auth', {
|
||||||
|
headers: { Origin: 'https://other.invalid' },
|
||||||
|
data: { action: 'logout' },
|
||||||
|
})
|
||||||
|
).status(),
|
||||||
|
).toBe(403)
|
||||||
|
const result = await request.post('/api/auth', {
|
||||||
|
headers: { Origin: origin },
|
||||||
|
data: {
|
||||||
|
action: 'setup',
|
||||||
|
email: '[email protected]',
|
||||||
|
name: 'Intruder',
|
||||||
|
password: 'Long-enough-password',
|
||||||
|
setupToken: 'arbitrary-token',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(result.status()).toBe(409)
|
||||||
|
await context.clearCookies()
|
||||||
|
await page.goto('/setup')
|
||||||
|
await expect(page).toHaveURL(/\/login$/)
|
||||||
|
})
|
||||||
@@ -296,6 +296,7 @@ test('keeps an open draft through remote edits and rejects its stale save', asyn
|
|||||||
baseURL,
|
baseURL,
|
||||||
}) => {
|
}) => {
|
||||||
const other = await browser.newContext({
|
const other = await browser.newContext({
|
||||||
|
storageState: await page.context().storageState(),
|
||||||
baseURL,
|
baseURL,
|
||||||
extraHTTPHeaders: { 'Tailscale-User-Login': '[email protected]' },
|
extraHTTPHeaders: { 'Tailscale-User-Login': '[email protected]' },
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ test('creates temporary research, edits it, persists explicitly and reopens it o
|
|||||||
).not.toBe(true)
|
).not.toBe(true)
|
||||||
expect(savedCount()).toEqual({ count: 1 })
|
expect(savedCount()).toEqual({ count: 1 })
|
||||||
const other = await browser.newContext({
|
const other = await browser.newContext({
|
||||||
|
storageState: await page.context().storageState(),
|
||||||
baseURL,
|
baseURL,
|
||||||
extraHTTPHeaders: { 'Tailscale-User-Login': '[email protected]' },
|
extraHTTPHeaders: { 'Tailscale-User-Login': '[email protected]' },
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -46,6 +46,8 @@
|
|||||||
--add-flags "$out/lib/twitter-lite/server/index.mjs"
|
--add-flags "$out/lib/twitter-lite/server/index.mjs"
|
||||||
makeWrapper ${nixpkgs.lib.getExe pkgs.nodejs_22} $out/bin/twitter-lite-backup \
|
makeWrapper ${nixpkgs.lib.getExe pkgs.nodejs_22} $out/bin/twitter-lite-backup \
|
||||||
--add-flags "$out/lib/twitter-lite/server/tools/backup-database.js"
|
--add-flags "$out/lib/twitter-lite/server/tools/backup-database.js"
|
||||||
|
makeWrapper ${nixpkgs.lib.getExe pkgs.nodejs_22} $out/bin/twitter-lite-setup \
|
||||||
|
--add-flags "$out/lib/twitter-lite/server/tools/account-setup.js"
|
||||||
runHook postInstall
|
runHook postInstall
|
||||||
'';
|
'';
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -41,7 +41,8 @@
|
|||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test:live": "TWITTER_LITE_LIVE=1 vitest run tests/live/relay.test.ts",
|
"test:live": "TWITTER_LITE_LIVE=1 vitest run tests/live/relay.test.ts",
|
||||||
"lint:ui": "oxlint src"
|
"lint:ui": "oxlint src",
|
||||||
|
"account:setup": "node --env-file-if-exists=.env.local --import tsx scripts/account-setup.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "1.8.0",
|
"@base-ui/react": "1.8.0",
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export default defineConfig({
|
|||||||
timeout: 120_000,
|
timeout: 120_000,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
command: `TWITTER_LITE_CODEX_URL= TWITTER_LITE_REPORT_ROOT= TWITTER_LITE_CODEX_MODEL= TWITTER_LITE_MASTODON_ORIGINS= TWITTER_LITE_CREDENTIAL_KEY_FILE= TWITTER_LITE_DB_PATH=${databasePath} TWITTER_LITE_ORIGIN=http://127.0.0.1:${appPort} [email protected] TWITTER_RELAY_BASE_URL=http://127.0.0.1:${relayPort} BIRD_PROFILE_NAME=e2e pnpm exec vite dev --host 127.0.0.1 --port ${appPort} --strictPort`,
|
command: `WORKSPACE_SETUP_TOKEN=isolated-e2e-setup-token-not-for-production TWITTER_LITE_CODEX_URL= TWITTER_LITE_REPORT_ROOT= TWITTER_LITE_CODEX_MODEL= TWITTER_LITE_MASTODON_ORIGINS= TWITTER_LITE_CREDENTIAL_KEY_FILE= TWITTER_LITE_DB_PATH=${databasePath} TWITTER_LITE_ORIGIN=http://127.0.0.1:${appPort} [email protected] TWITTER_RELAY_BASE_URL=http://127.0.0.1:${relayPort} BIRD_PROFILE_NAME=e2e pnpm exec vite dev --host 127.0.0.1 --port ${appPort} --strictPort`,
|
||||||
port: appPort,
|
port: appPort,
|
||||||
reuseExistingServer: false,
|
reuseExistingServer: false,
|
||||||
timeout: 120_000,
|
timeout: 120_000,
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { getSetupToken, readAuthState } from '../src/features/auth/auth.server'
|
||||||
|
|
||||||
|
if (!readAuthState().needsSetup) {
|
||||||
|
console.error(
|
||||||
|
'This workspace already has an owner. Sign in with your existing account.',
|
||||||
|
)
|
||||||
|
process.exitCode = 1
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
'Open /setup in your workspace and enter this one-time setup code:',
|
||||||
|
)
|
||||||
|
console.log(getSetupToken())
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
import { build } from 'vite'
|
import { build } from 'vite'
|
||||||
|
|
||||||
|
for (const entry of ['backup-database', 'account-setup']) {
|
||||||
await build({
|
await build({
|
||||||
configFile: false,
|
configFile: false,
|
||||||
build: {
|
build: {
|
||||||
ssr: 'scripts/backup-database.ts',
|
ssr: `scripts/${entry}.ts`,
|
||||||
outDir: '.output/server/tools',
|
outDir: '.output/server/tools',
|
||||||
|
emptyOutDir: false,
|
||||||
rollupOptions: { external: ['better-sqlite3'] },
|
rollupOptions: { external: ['better-sqlite3'] },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Link, useLocation } from '@tanstack/react-router'
|
import { Link, useLocation, useRouteContext } from '@tanstack/react-router'
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
House,
|
House,
|
||||||
@@ -35,6 +35,7 @@ export function WorkspaceNavigation({
|
|||||||
footer,
|
footer,
|
||||||
hasTemporaryDecks,
|
hasTemporaryDecks,
|
||||||
}: WorkspaceNavigationProps) {
|
}: WorkspaceNavigationProps) {
|
||||||
|
const { auth } = useRouteContext({ from: '__root__' })
|
||||||
const [managingAccounts, setManagingAccounts] = useState(false)
|
const [managingAccounts, setManagingAccounts] = useState(false)
|
||||||
const pathname = useLocation({ select: (location) => location.pathname })
|
const pathname = useLocation({ select: (location) => location.pathname })
|
||||||
const navigation = [
|
const navigation = [
|
||||||
@@ -129,7 +130,9 @@ export function WorkspaceNavigation({
|
|||||||
onClick={() => setManagingAccounts(true)}
|
onClick={() => setManagingAccounts(true)}
|
||||||
>
|
>
|
||||||
<Avatar className="size-8">
|
<Avatar className="size-8">
|
||||||
<AvatarFallback>Y</AvatarFallback>
|
<AvatarFallback>
|
||||||
|
{auth.owner?.name.slice(0, 1).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
export type SafeOwner = {
|
||||||
|
id: number
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
onboardingCompletedAt: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AuthState = { needsSetup: boolean; owner: SafeOwner | null }
|
||||||
|
|
||||||
|
type AuthInput =
|
||||||
|
| { action: 'login'; email: string; password: string }
|
||||||
|
| {
|
||||||
|
action: 'setup'
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
name: string
|
||||||
|
setupToken: string
|
||||||
|
}
|
||||||
|
| { action: 'logout' }
|
||||||
|
| { action: 'onboard'; name: string }
|
||||||
|
|
||||||
|
export async function authRequest(input: AuthInput): Promise<AuthState> {
|
||||||
|
const response = await fetch('/api/auth', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
})
|
||||||
|
const result = await response.json()
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(result.error || 'Something went wrong. Please try again.')
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { ArrowRight, LockKeyhole } from 'lucide-react'
|
||||||
|
import { type FormEvent, type ReactNode, useState } from 'react'
|
||||||
|
import { Button } from '#/components/ui/button'
|
||||||
|
import { Input } from '#/components/ui/input'
|
||||||
|
import { Label } from '#/components/ui/label'
|
||||||
|
import { authRequest } from './auth-client'
|
||||||
|
import './auth.css'
|
||||||
|
|
||||||
|
export function AuthFrame({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<main className="auth-page">
|
||||||
|
<div className="auth-panel">
|
||||||
|
<div className="auth-brand">
|
||||||
|
<LockKeyhole size={16} aria-hidden="true" />
|
||||||
|
Personal Workspace
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoginPage() {
|
||||||
|
return <CredentialsForm setup={false} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetupPage() {
|
||||||
|
return <CredentialsForm setup />
|
||||||
|
}
|
||||||
|
|
||||||
|
function CredentialsForm({ setup }: { setup: boolean }) {
|
||||||
|
const [pending, setPending] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (pending) return
|
||||||
|
const data = new FormData(event.currentTarget)
|
||||||
|
const email = String(data.get('email') ?? '').trim()
|
||||||
|
const password = String(data.get('password') ?? '')
|
||||||
|
if (setup && password !== data.get('confirmPassword')) {
|
||||||
|
setError('Passwords do not match.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setPending(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const state = await authRequest(
|
||||||
|
setup
|
||||||
|
? {
|
||||||
|
action: 'setup',
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
name: String(data.get('name') ?? '').trim(),
|
||||||
|
setupToken: String(data.get('setupToken') ?? '').trim(),
|
||||||
|
}
|
||||||
|
: { action: 'login', email, password },
|
||||||
|
)
|
||||||
|
if (!state.owner?.onboardingCompletedAt) {
|
||||||
|
window.location.assign('/onboarding')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const returnTo = new URLSearchParams(window.location.search).get(
|
||||||
|
'returnTo',
|
||||||
|
)
|
||||||
|
const destination =
|
||||||
|
returnTo &&
|
||||||
|
['/', '/deck', '/support', '/journal', '/inbox', '/vitals'].includes(
|
||||||
|
returnTo,
|
||||||
|
)
|
||||||
|
? returnTo
|
||||||
|
: '/'
|
||||||
|
window.location.assign(destination)
|
||||||
|
} catch (cause) {
|
||||||
|
setError(
|
||||||
|
cause instanceof Error
|
||||||
|
? cause.message
|
||||||
|
: 'Unable to sign in. Please try again.',
|
||||||
|
)
|
||||||
|
setPending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthFrame>
|
||||||
|
<header className="auth-heading">
|
||||||
|
<h1>{setup ? 'Make yourself at home.' : 'Welcome back.'}</h1>
|
||||||
|
<p>
|
||||||
|
{setup
|
||||||
|
? 'Create the owner account for your personal workspace.'
|
||||||
|
: 'Sign in to your personal workspace.'}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<form onSubmit={submit} className="auth-form" aria-busy={pending}>
|
||||||
|
{setup && (
|
||||||
|
<div className="auth-field">
|
||||||
|
<Label htmlFor="name">Your name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
autoComplete="given-name"
|
||||||
|
required
|
||||||
|
maxLength={80}
|
||||||
|
disabled={pending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="auth-field">
|
||||||
|
<Label htmlFor="email">Email address</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
maxLength={254}
|
||||||
|
disabled={pending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="auth-field">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete={setup ? 'new-password' : 'current-password'}
|
||||||
|
required
|
||||||
|
minLength={setup ? 15 : undefined}
|
||||||
|
maxLength={128}
|
||||||
|
aria-describedby={setup ? 'password-hint' : undefined}
|
||||||
|
disabled={pending}
|
||||||
|
/>
|
||||||
|
{setup && (
|
||||||
|
<p id="password-hint" className="auth-hint">
|
||||||
|
Use 15–128 characters. A few memorable words work well.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{setup && (
|
||||||
|
<>
|
||||||
|
<div className="auth-field">
|
||||||
|
<Label htmlFor="confirmPassword">Confirm password</Label>
|
||||||
|
<Input
|
||||||
|
id="confirmPassword"
|
||||||
|
name="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
minLength={15}
|
||||||
|
maxLength={128}
|
||||||
|
disabled={pending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="auth-field">
|
||||||
|
<Label htmlFor="setupToken">Setup code</Label>
|
||||||
|
<Input
|
||||||
|
id="setupToken"
|
||||||
|
name="setupToken"
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
required
|
||||||
|
aria-describedby="setup-hint"
|
||||||
|
disabled={pending}
|
||||||
|
/>
|
||||||
|
<p id="setup-hint" className="auth-hint">
|
||||||
|
Use the setup code provided by your server administrator.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p className="auth-error" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="lg"
|
||||||
|
disabled={pending}
|
||||||
|
className="auth-submit"
|
||||||
|
>
|
||||||
|
{pending
|
||||||
|
? setup
|
||||||
|
? 'Creating account…'
|
||||||
|
: 'Signing in…'
|
||||||
|
: setup
|
||||||
|
? 'Create account'
|
||||||
|
: 'Sign in'}
|
||||||
|
<ArrowRight aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<p className="auth-footnote">
|
||||||
|
{setup
|
||||||
|
? 'This workspace has one owner. Connections can be added later.'
|
||||||
|
: 'A private workspace. Access is limited to its owner.'}
|
||||||
|
</p>
|
||||||
|
</AuthFrame>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
.auth-page {
|
||||||
|
min-height: 100svh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 48px 24px;
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
.auth-panel {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 390px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.auth-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 44px;
|
||||||
|
}
|
||||||
|
.auth-heading {
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
.auth-heading h1 {
|
||||||
|
font-size: 27px;
|
||||||
|
font-weight: 550;
|
||||||
|
letter-spacing: -0.8px;
|
||||||
|
line-height: 1.2;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.auth-heading p {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.auth-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
.auth-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 9px;
|
||||||
|
}
|
||||||
|
.auth-field input {
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
.auth-hint,
|
||||||
|
.auth-footnote,
|
||||||
|
.auth-prototype {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
.auth-submit {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 4px;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-inline: 14px;
|
||||||
|
}
|
||||||
|
.auth-footnote {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
.auth-error {
|
||||||
|
color: var(--destructive);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.auth-step {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.auth-overview {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 0 28px;
|
||||||
|
display: grid;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
.auth-overview li {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.auth-overview svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
.auth-overview strong {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.auth-overview p {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.auth-prototype {
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.auth-actions {
|
||||||
|
margin-top: 26px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.auth-footer {
|
||||||
|
margin-top: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.auth-footer span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.auth-page {
|
||||||
|
padding: 32px 24px;
|
||||||
|
}
|
||||||
|
.auth-brand {
|
||||||
|
margin-bottom: 36px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import { createHash, randomBytes, scrypt, timingSafeEqual } from 'node:crypto'
|
||||||
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||||
|
import { dirname, isAbsolute } from 'node:path'
|
||||||
|
import { eq, lte } from 'drizzle-orm'
|
||||||
|
import { type AppDatabase, getDatabase } from '../storage/database.server'
|
||||||
|
import { authSessions, authThrottle, workspaceOwner } from '../storage/schema'
|
||||||
|
import type { SafeOwner } from './auth-client'
|
||||||
|
|
||||||
|
export const SESSION_MAX_AGE_SECONDS = 30 * 24 * 60 * 60
|
||||||
|
const derive = (password: string, salt: string) =>
|
||||||
|
new Promise<Buffer>((resolve, reject) =>
|
||||||
|
scrypt(password, salt, 64, scryptOptions, (error, key) =>
|
||||||
|
error ? reject(error) : resolve(key),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const scryptOptions = { N: 2 ** 17, r: 8, p: 1, maxmem: 256 * 1024 * 1024 }
|
||||||
|
const throttleWindow = 15 * 60_000
|
||||||
|
|
||||||
|
export class AuthError extends Error {
|
||||||
|
constructor(
|
||||||
|
public status: number,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'AuthError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const hash = (value: string) => createHash('sha256').update(value).digest('hex')
|
||||||
|
const equal = (left: string, right: string) =>
|
||||||
|
timingSafeEqual(Buffer.from(hash(left)), Buffer.from(hash(right)))
|
||||||
|
function safeOwner(owner: typeof workspaceOwner.$inferSelect): SafeOwner {
|
||||||
|
return {
|
||||||
|
id: owner.id,
|
||||||
|
email: owner.email,
|
||||||
|
name: owner.name,
|
||||||
|
onboardingCompletedAt: owner.onboardingCompletedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function readAuthState(database = getDatabase()) {
|
||||||
|
return {
|
||||||
|
needsSetup: !database
|
||||||
|
.select({ id: workspaceOwner.id })
|
||||||
|
.from(workspaceOwner)
|
||||||
|
.get(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** Bootstrap secret is read only by the server or operator CLI, never sent to clients. */
|
||||||
|
export function getSetupToken() {
|
||||||
|
const configured = process.env.WORKSPACE_SETUP_TOKEN
|
||||||
|
if (configured) {
|
||||||
|
if (configured.length < 32)
|
||||||
|
throw new AuthError(
|
||||||
|
503,
|
||||||
|
'The setup token must contain at least 32 characters.',
|
||||||
|
)
|
||||||
|
return configured
|
||||||
|
}
|
||||||
|
const dbPath = process.env.TWITTER_LITE_DB_PATH
|
||||||
|
if (!dbPath || !isAbsolute(dbPath))
|
||||||
|
throw new AuthError(503, 'Configure a workspace database or setup token.')
|
||||||
|
const path = `${dbPath}.setup-token`
|
||||||
|
mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
|
||||||
|
try {
|
||||||
|
writeFileSync(path, randomBytes(32).toString('base64url'), {
|
||||||
|
mode: 0o600,
|
||||||
|
flag: 'wx',
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
|
||||||
|
}
|
||||||
|
const token = readFileSync(path, 'utf8').trim()
|
||||||
|
if (token.length < 32)
|
||||||
|
throw new AuthError(503, 'The workspace setup token is invalid.')
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
function consumeAttempt(database: AppDatabase) {
|
||||||
|
database.transaction(
|
||||||
|
(tx) => {
|
||||||
|
const now = Date.now()
|
||||||
|
const current = tx.select().from(authThrottle).get()
|
||||||
|
if (current && current.resetAt > now) {
|
||||||
|
if (current.attempts >= 10)
|
||||||
|
throw new AuthError(
|
||||||
|
429,
|
||||||
|
'Too many attempts. Please try again in 15 minutes.',
|
||||||
|
)
|
||||||
|
tx.update(authThrottle)
|
||||||
|
.set({ attempts: current.attempts + 1 })
|
||||||
|
.where(eq(authThrottle.id, 1))
|
||||||
|
.run()
|
||||||
|
} else {
|
||||||
|
tx.insert(authThrottle)
|
||||||
|
.values({ id: 1, attempts: 1, resetAt: now + throttleWindow })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: authThrottle.id,
|
||||||
|
set: { attempts: 1, resetAt: now + throttleWindow },
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ behavior: 'immediate' },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
function session(
|
||||||
|
database: Pick<AppDatabase, 'insert' | 'delete'>,
|
||||||
|
owner: typeof workspaceOwner.$inferSelect,
|
||||||
|
) {
|
||||||
|
const sessionToken = randomBytes(32).toString('base64url')
|
||||||
|
database
|
||||||
|
.delete(authSessions)
|
||||||
|
.where(lte(authSessions.expiresAt, Date.now()))
|
||||||
|
.run()
|
||||||
|
database
|
||||||
|
.insert(authSessions)
|
||||||
|
.values({
|
||||||
|
tokenHash: hash(sessionToken),
|
||||||
|
ownerId: owner.id,
|
||||||
|
expiresAt: Date.now() + SESSION_MAX_AGE_SECONDS * 1000,
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
database.delete(authThrottle).where(eq(authThrottle.id, 1)).run()
|
||||||
|
return { sessionToken, owner: safeOwner(owner) }
|
||||||
|
}
|
||||||
|
function cleanName(name: string) {
|
||||||
|
const cleaned = name.trim()
|
||||||
|
if (!cleaned || cleaned.length > 80)
|
||||||
|
throw new AuthError(400, 'Enter a name of up to 80 characters.')
|
||||||
|
return cleaned
|
||||||
|
}
|
||||||
|
export async function createOwner(
|
||||||
|
input: { email: string; password: string; name: string; setupToken: string },
|
||||||
|
database = getDatabase(),
|
||||||
|
) {
|
||||||
|
if (!readAuthState(database).needsSetup)
|
||||||
|
throw new AuthError(
|
||||||
|
409,
|
||||||
|
'This workspace is already set up. Please sign in.',
|
||||||
|
)
|
||||||
|
consumeAttempt(database)
|
||||||
|
if (!equal(input.setupToken, getSetupToken()))
|
||||||
|
throw new AuthError(401, 'Invalid setup credentials.')
|
||||||
|
const email = input.email.trim().toLowerCase()
|
||||||
|
if (email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
|
||||||
|
throw new AuthError(400, 'Enter a valid email address.')
|
||||||
|
if (input.password.length < 15 || input.password.length > 128)
|
||||||
|
throw new AuthError(400, 'Use a password between 15 and 128 characters.')
|
||||||
|
const name = cleanName(input.name)
|
||||||
|
const salt = randomBytes(16).toString('hex')
|
||||||
|
const derived = (await derive(input.password, salt)) as Buffer
|
||||||
|
return database.transaction(
|
||||||
|
(tx) => {
|
||||||
|
if (tx.select().from(workspaceOwner).get())
|
||||||
|
throw new AuthError(
|
||||||
|
409,
|
||||||
|
'This workspace is already set up. Please sign in.',
|
||||||
|
)
|
||||||
|
const owner = {
|
||||||
|
id: 1,
|
||||||
|
email,
|
||||||
|
name,
|
||||||
|
passwordHash: `${salt}:${derived.toString('hex')}`,
|
||||||
|
onboardingCompletedAt: null,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
}
|
||||||
|
tx.insert(workspaceOwner).values(owner).run()
|
||||||
|
return session(tx, owner)
|
||||||
|
},
|
||||||
|
{ behavior: 'immediate' },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
export async function signIn(
|
||||||
|
input: { email: string; password: string },
|
||||||
|
database = getDatabase(),
|
||||||
|
) {
|
||||||
|
consumeAttempt(database)
|
||||||
|
const owner = database.select().from(workspaceOwner).get()
|
||||||
|
if (input.password.length > 128 || input.email.length > 254)
|
||||||
|
throw new AuthError(401, 'Invalid email or password.')
|
||||||
|
const [salt = '', expected = ''] = owner?.passwordHash.split(':') ?? [
|
||||||
|
'0'.repeat(32),
|
||||||
|
'0'.repeat(128),
|
||||||
|
]
|
||||||
|
const actual = (await derive(input.password, salt)) as Buffer
|
||||||
|
if (
|
||||||
|
!owner ||
|
||||||
|
!equal(input.email.trim().toLowerCase(), owner.email) ||
|
||||||
|
!timingSafeEqual(actual, Buffer.from(expected, 'hex'))
|
||||||
|
)
|
||||||
|
throw new AuthError(401, 'Invalid email or password.')
|
||||||
|
return session(database, owner)
|
||||||
|
}
|
||||||
|
export function getSession(
|
||||||
|
token: string | undefined,
|
||||||
|
database = getDatabase(),
|
||||||
|
): SafeOwner | null {
|
||||||
|
if (!token || token.length > 128) return null
|
||||||
|
const found = database
|
||||||
|
.select()
|
||||||
|
.from(authSessions)
|
||||||
|
.where(eq(authSessions.tokenHash, hash(token)))
|
||||||
|
.get()
|
||||||
|
if (!found) return null
|
||||||
|
if (found.expiresAt <= Date.now()) {
|
||||||
|
database
|
||||||
|
.delete(authSessions)
|
||||||
|
.where(eq(authSessions.tokenHash, found.tokenHash))
|
||||||
|
.run()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const owner = database
|
||||||
|
.select()
|
||||||
|
.from(workspaceOwner)
|
||||||
|
.where(eq(workspaceOwner.id, found.ownerId))
|
||||||
|
.get()
|
||||||
|
return owner ? safeOwner(owner) : null
|
||||||
|
}
|
||||||
|
export function signOut(token: string | undefined, database = getDatabase()) {
|
||||||
|
if (token)
|
||||||
|
database
|
||||||
|
.delete(authSessions)
|
||||||
|
.where(eq(authSessions.tokenHash, hash(token)))
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
export function completeOnboarding(
|
||||||
|
token: string,
|
||||||
|
input: { name: string },
|
||||||
|
database = getDatabase(),
|
||||||
|
): SafeOwner {
|
||||||
|
const owner = getSession(token, database)
|
||||||
|
if (!owner) throw new AuthError(401, 'Please sign in.')
|
||||||
|
const update = {
|
||||||
|
name: cleanName(input.name),
|
||||||
|
onboardingCompletedAt: owner.onboardingCompletedAt ?? Date.now(),
|
||||||
|
}
|
||||||
|
database
|
||||||
|
.update(workspaceOwner)
|
||||||
|
.set(update)
|
||||||
|
.where(eq(workspaceOwner.id, owner.id))
|
||||||
|
.run()
|
||||||
|
return { ...owner, ...update }
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { openDatabase } from '../storage/database.server'
|
||||||
|
import { authSessions, authThrottle, workspaceOwner } from '../storage/schema'
|
||||||
|
import {
|
||||||
|
completeOnboarding,
|
||||||
|
createOwner,
|
||||||
|
getSession,
|
||||||
|
getSetupToken,
|
||||||
|
readAuthState,
|
||||||
|
SESSION_MAX_AGE_SECONDS,
|
||||||
|
signIn,
|
||||||
|
signOut,
|
||||||
|
} from './auth.server'
|
||||||
|
|
||||||
|
let database: ReturnType<typeof openDatabase>
|
||||||
|
const credentials = {
|
||||||
|
email: '[email protected]',
|
||||||
|
password: 'correct horse battery staple',
|
||||||
|
name: 'Owner',
|
||||||
|
setupToken: 's'.repeat(32),
|
||||||
|
}
|
||||||
|
beforeEach(() => {
|
||||||
|
database = openDatabase(':memory:')
|
||||||
|
vi.stubEnv('WORKSPACE_SETUP_TOKEN', credentials.setupToken)
|
||||||
|
})
|
||||||
|
afterEach(() => {
|
||||||
|
database.$client.close()
|
||||||
|
vi.unstubAllEnvs()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
describe('owner authentication', () => {
|
||||||
|
it('requires a private setup token and stores only derived credentials and session tokens', async () => {
|
||||||
|
expect(readAuthState(database)).toEqual({ needsSetup: true })
|
||||||
|
await expect(
|
||||||
|
createOwner({ ...credentials, setupToken: 'wrong' }, database),
|
||||||
|
).rejects.toMatchObject({ status: 401 })
|
||||||
|
expect(readAuthState(database).needsSetup).toBe(true)
|
||||||
|
const result = await createOwner(credentials, database)
|
||||||
|
expect(readAuthState(database)).toEqual({ needsSetup: false })
|
||||||
|
expect(result.owner.email).toBe(credentials.email)
|
||||||
|
expect(result.owner).not.toHaveProperty('passwordHash')
|
||||||
|
expect(
|
||||||
|
database.select().from(workspaceOwner).get()?.passwordHash,
|
||||||
|
).not.toContain(credentials.password)
|
||||||
|
expect(database.select().from(authSessions).get()?.tokenHash).not.toEqual(
|
||||||
|
result.sessionToken,
|
||||||
|
)
|
||||||
|
expect(getSession(result.sessionToken, database)).toEqual(result.owner)
|
||||||
|
await expect(createOwner(credentials, database)).rejects.toMatchObject({
|
||||||
|
status: 409,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
it('only allows one owner even when setup requests race', async () => {
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
createOwner(credentials, database),
|
||||||
|
createOwner(credentials, database),
|
||||||
|
])
|
||||||
|
expect(
|
||||||
|
results.filter((result) => result.status === 'fulfilled'),
|
||||||
|
).toHaveLength(1)
|
||||||
|
expect(database.select().from(workspaceOwner).all()).toHaveLength(1)
|
||||||
|
})
|
||||||
|
it('checks the email and password and revokes logged-out sessions', async () => {
|
||||||
|
await createOwner(credentials, database)
|
||||||
|
await expect(
|
||||||
|
signIn({ ...credentials, password: 'wrong' }, database),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
status: 401,
|
||||||
|
message: 'Invalid email or password.',
|
||||||
|
})
|
||||||
|
await expect(
|
||||||
|
signIn({ ...credentials, email: '[email protected]' }, database),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
status: 401,
|
||||||
|
message: 'Invalid email or password.',
|
||||||
|
})
|
||||||
|
const result = await signIn(
|
||||||
|
{ ...credentials, email: '[email protected]' },
|
||||||
|
database,
|
||||||
|
)
|
||||||
|
expect(database.select().from(authThrottle).all()).toHaveLength(0)
|
||||||
|
expect(getSession('invented', database)).toBeNull()
|
||||||
|
expect(getSession(result.sessionToken, database)).not.toBeNull()
|
||||||
|
signOut(result.sessionToken, database)
|
||||||
|
expect(getSession(result.sessionToken, database)).toBeNull()
|
||||||
|
})
|
||||||
|
it('expires sessions and stores onboarding completion', async () => {
|
||||||
|
const result = await createOwner(credentials, database)
|
||||||
|
expect(result.owner.onboardingCompletedAt).toBeNull()
|
||||||
|
const owner = completeOnboarding(
|
||||||
|
result.sessionToken,
|
||||||
|
{ name: 'Yuta' },
|
||||||
|
database,
|
||||||
|
)
|
||||||
|
expect(owner.name).toBe('Yuta')
|
||||||
|
expect(owner.onboardingCompletedAt).toBeTypeOf('number')
|
||||||
|
expect(getSession(result.sessionToken, database)).toEqual(owner)
|
||||||
|
expect(() =>
|
||||||
|
completeOnboarding('invalid', { name: 'Other' }, database),
|
||||||
|
).toThrow('Please sign in.')
|
||||||
|
vi.spyOn(Date, 'now').mockReturnValue(
|
||||||
|
Date.now() + SESSION_MAX_AGE_SECONDS * 1000 + 1,
|
||||||
|
)
|
||||||
|
expect(getSession(result.sessionToken, database)).toBeNull()
|
||||||
|
expect(database.select().from(authSessions).all()).toHaveLength(0)
|
||||||
|
})
|
||||||
|
it('bounds setup and login attempts persistently and permits retry after cooldown', async () => {
|
||||||
|
for (let index = 0; index < 10; index++)
|
||||||
|
await expect(
|
||||||
|
createOwner({ ...credentials, setupToken: 'wrong' }, database),
|
||||||
|
).rejects.toMatchObject({ status: 401 })
|
||||||
|
await expect(signIn(credentials, database)).rejects.toMatchObject({
|
||||||
|
status: 429,
|
||||||
|
})
|
||||||
|
vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 15 * 60_000 + 1)
|
||||||
|
await expect(createOwner(credentials, database)).resolves.toHaveProperty(
|
||||||
|
'sessionToken',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
it('rejects short passwords and invalid identity fields', async () => {
|
||||||
|
await expect(
|
||||||
|
createOwner({ ...credentials, password: 'short' }, database),
|
||||||
|
).rejects.toMatchObject({ status: 400 })
|
||||||
|
await expect(
|
||||||
|
createOwner({ ...credentials, email: 'invalid' }, database),
|
||||||
|
).rejects.toMatchObject({ status: 400 })
|
||||||
|
await expect(
|
||||||
|
createOwner({ ...credentials, name: ' ' }, database),
|
||||||
|
).rejects.toMatchObject({ status: 400 })
|
||||||
|
})
|
||||||
|
it('creates a stable local setup token with private file permissions', () => {
|
||||||
|
const directory = mkdtempSync(join(tmpdir(), 'workspace-setup-'))
|
||||||
|
try {
|
||||||
|
vi.stubEnv('WORKSPACE_SETUP_TOKEN', '')
|
||||||
|
const path = join(directory, 'workspace.sqlite')
|
||||||
|
vi.stubEnv('TWITTER_LITE_DB_PATH', path)
|
||||||
|
const token = getSetupToken()
|
||||||
|
expect(token.length).toBeGreaterThanOrEqual(32)
|
||||||
|
expect(getSetupToken()).toBe(token)
|
||||||
|
expect(readFileSync(`${path}.setup-token`, 'utf8')).toBe(token)
|
||||||
|
expect(statSync(`${path}.setup-token`).mode & 0o777).toBe(0o600)
|
||||||
|
} finally {
|
||||||
|
rmSync(directory, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { getSession, readAuthState } from './auth.server'
|
||||||
|
import { sessionToken } from './http.server'
|
||||||
|
|
||||||
|
const publicPaths = new Set(['/login', '/setup', '/api/auth'])
|
||||||
|
|
||||||
|
export function checkSessionAccess(request: Request): Response | null {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
if (publicPaths.has(url.pathname)) return null
|
||||||
|
const owner = getSession(sessionToken(request))
|
||||||
|
if (owner?.onboardingCompletedAt) return null
|
||||||
|
if (owner && url.pathname === '/onboarding') return null
|
||||||
|
const document =
|
||||||
|
request.method === 'GET' &&
|
||||||
|
request.headers.get('accept')?.includes('text/html') &&
|
||||||
|
!url.pathname.startsWith('/api/') &&
|
||||||
|
!url.pathname.startsWith('/_serverFn/')
|
||||||
|
if (document) {
|
||||||
|
const location = owner
|
||||||
|
? '/onboarding'
|
||||||
|
: readAuthState().needsSetup
|
||||||
|
? '/setup'
|
||||||
|
: '/login'
|
||||||
|
return new Response(null, {
|
||||||
|
status: 303,
|
||||||
|
headers: { location, 'cache-control': 'no-store' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
error: owner
|
||||||
|
? 'Complete onboarding to continue.'
|
||||||
|
: 'Sign in to continue.',
|
||||||
|
},
|
||||||
|
{ status: owner ? 403 : 401, headers: { 'cache-control': 'no-store' } },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import {
|
||||||
|
AuthError,
|
||||||
|
completeOnboarding,
|
||||||
|
createOwner,
|
||||||
|
getSession,
|
||||||
|
readAuthState,
|
||||||
|
SESSION_MAX_AGE_SECONDS,
|
||||||
|
signIn,
|
||||||
|
signOut,
|
||||||
|
} from './auth.server'
|
||||||
|
|
||||||
|
const SESSION_COOKIE = 'workspace_session'
|
||||||
|
|
||||||
|
export function sessionToken(request: Request) {
|
||||||
|
return request.headers
|
||||||
|
.get('cookie')
|
||||||
|
?.split(';')
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.find((part) => part.startsWith(`${SESSION_COOKIE}=`))
|
||||||
|
?.slice(SESSION_COOKIE.length + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authState(request: Request) {
|
||||||
|
return { ...readAuthState(), owner: getSession(sessionToken(request)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function cookie(token: string, clear = false) {
|
||||||
|
const secure = process.env.TWITTER_LITE_ORIGIN?.startsWith('https:')
|
||||||
|
return `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${clear ? 0 : SESSION_MAX_AGE_SECONDS}${secure ? '; Secure' : ''}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function authEndpoint(request: Request) {
|
||||||
|
const headers = new Headers({
|
||||||
|
'cache-control': 'no-store',
|
||||||
|
vary: 'Cookie',
|
||||||
|
'content-type': 'application/json',
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
if (request.method === 'GET')
|
||||||
|
return Response.json(authState(request), { headers })
|
||||||
|
if (request.method !== 'POST')
|
||||||
|
return new Response(null, { status: 405, headers })
|
||||||
|
if (!request.headers.get('content-type')?.startsWith('application/json'))
|
||||||
|
throw new AuthError(400, 'Use a JSON request.')
|
||||||
|
const body = await request.text()
|
||||||
|
if (body.length > 8192) throw new AuthError(413, 'Request is too large.')
|
||||||
|
let data: Record<string, unknown>
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(body)
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
||||||
|
throw new Error('invalid body')
|
||||||
|
data = parsed
|
||||||
|
} catch {
|
||||||
|
throw new AuthError(400, 'Check the form and try again.')
|
||||||
|
}
|
||||||
|
const text = (key: string) =>
|
||||||
|
typeof data[key] === 'string' ? data[key] : ''
|
||||||
|
const oldToken = sessionToken(request)
|
||||||
|
switch (data.action) {
|
||||||
|
case 'setup':
|
||||||
|
case 'login': {
|
||||||
|
const result =
|
||||||
|
data.action === 'setup'
|
||||||
|
? await createOwner({
|
||||||
|
email: text('email'),
|
||||||
|
password: text('password'),
|
||||||
|
name: text('name'),
|
||||||
|
setupToken: text('setupToken'),
|
||||||
|
})
|
||||||
|
: await signIn({ email: text('email'), password: text('password') })
|
||||||
|
signOut(oldToken)
|
||||||
|
headers.set('set-cookie', cookie(result.sessionToken))
|
||||||
|
return Response.json(
|
||||||
|
{ needsSetup: false, owner: result.owner },
|
||||||
|
{ headers },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'onboard': {
|
||||||
|
const owner = completeOnboarding(oldToken ?? '', { name: text('name') })
|
||||||
|
return Response.json({ needsSetup: false, owner }, { headers })
|
||||||
|
}
|
||||||
|
case 'logout':
|
||||||
|
signOut(oldToken)
|
||||||
|
headers.set('set-cookie', cookie('', true))
|
||||||
|
return Response.json(
|
||||||
|
{ needsSetup: readAuthState().needsSetup, owner: null },
|
||||||
|
{ headers },
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
throw new AuthError(400, 'Unknown action.')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError)
|
||||||
|
return Response.json(
|
||||||
|
{ error: error.message },
|
||||||
|
{ status: error.status, headers },
|
||||||
|
)
|
||||||
|
return Response.json(
|
||||||
|
{ error: 'Unable to complete the request. Please try again.' },
|
||||||
|
{ status: 500, headers },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { useRouteContext } from '@tanstack/react-router'
|
||||||
|
import { ArrowRight, BookOpen, House, MessagesSquare } from 'lucide-react'
|
||||||
|
import { type FormEvent, useState } from 'react'
|
||||||
|
import { Button } from '#/components/ui/button'
|
||||||
|
import { Input } from '#/components/ui/input'
|
||||||
|
import { Label } from '#/components/ui/label'
|
||||||
|
import { authRequest } from './auth-client'
|
||||||
|
import { AuthFrame } from './auth-page'
|
||||||
|
|
||||||
|
export function OnboardingPage() {
|
||||||
|
const { auth } = useRouteContext({ from: '__root__' })
|
||||||
|
const [name, setName] = useState(auth.owner?.name ?? '')
|
||||||
|
const [step, setStep] = useState(1)
|
||||||
|
const [pending, setPending] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function finish() {
|
||||||
|
setPending(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await authRequest({ action: 'onboard', name: name.trim() })
|
||||||
|
window.location.assign('/')
|
||||||
|
} catch (cause) {
|
||||||
|
setError(
|
||||||
|
cause instanceof Error
|
||||||
|
? cause.message
|
||||||
|
: 'Unable to save. Please try again.',
|
||||||
|
)
|
||||||
|
setPending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signOut() {
|
||||||
|
setPending(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await authRequest({ action: 'logout' })
|
||||||
|
window.location.assign('/login')
|
||||||
|
} catch (cause) {
|
||||||
|
setError(
|
||||||
|
cause instanceof Error
|
||||||
|
? cause.message
|
||||||
|
: 'Unable to sign out. Please try again.',
|
||||||
|
)
|
||||||
|
setPending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function next(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!name.trim()) return
|
||||||
|
setStep(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthFrame>
|
||||||
|
<p className="auth-step">{step} of 2</p>
|
||||||
|
{step === 1 ? (
|
||||||
|
<>
|
||||||
|
<header className="auth-heading">
|
||||||
|
<h1>A little context, to start.</h1>
|
||||||
|
<p>Your day, conversations, and reading, together in one place.</p>
|
||||||
|
</header>
|
||||||
|
<form className="auth-form" onSubmit={next}>
|
||||||
|
<div className="auth-field">
|
||||||
|
<Label htmlFor="displayName">What should we call you?</Label>
|
||||||
|
<Input
|
||||||
|
id="displayName"
|
||||||
|
name="displayName"
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
autoComplete="given-name"
|
||||||
|
required
|
||||||
|
maxLength={80}
|
||||||
|
disabled={pending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="auth-submit"
|
||||||
|
size="lg"
|
||||||
|
disabled={pending || !name.trim()}
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
<ArrowRight aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<header className="auth-heading">
|
||||||
|
<h1>Start with today.</h1>
|
||||||
|
<p>You can take it one thing at a time.</p>
|
||||||
|
</header>
|
||||||
|
<ul className="auth-overview">
|
||||||
|
<li>
|
||||||
|
<House aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<strong>A place to begin</strong>
|
||||||
|
<p>A brief and flexible tasks help you find your next step.</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<MessagesSquare aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<strong>Room for the everyday</strong>
|
||||||
|
<p>Keep conversations and quick notes close at hand.</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<BookOpen aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<strong>Follow your curiosity</strong>
|
||||||
|
<p>Explore reading and research alongside your day.</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p className="auth-prototype">
|
||||||
|
Home, contacts, notes, reading, and vitals currently use sample
|
||||||
|
data. You can add connections from your profile menu whenever you’re
|
||||||
|
ready.
|
||||||
|
</p>
|
||||||
|
<div className="auth-actions">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setStep(1)}
|
||||||
|
disabled={pending}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button size="lg" onClick={finish} disabled={pending}>
|
||||||
|
{pending ? 'Saving…' : 'Open workspace'}
|
||||||
|
<ArrowRight aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p className="auth-error" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<footer className="auth-footer">
|
||||||
|
<span>{auth.owner?.email}</span>
|
||||||
|
<Button variant="ghost" size="sm" onClick={signOut} disabled={pending}>
|
||||||
|
Sign out
|
||||||
|
</Button>
|
||||||
|
</footer>
|
||||||
|
</AuthFrame>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { createIsomorphicFn } from '@tanstack/react-start'
|
||||||
|
import type { AuthState } from './auth-client'
|
||||||
|
|
||||||
|
export const loadAuthState = createIsomorphicFn()
|
||||||
|
.server(async (): Promise<AuthState> => {
|
||||||
|
const { getRequest } = await import('@tanstack/react-start/server')
|
||||||
|
const { authState } = await import('./http.server')
|
||||||
|
return authState(getRequest())
|
||||||
|
})
|
||||||
|
.client(async (): Promise<AuthState> => {
|
||||||
|
const response = await fetch('/api/auth', { cache: 'no-store' })
|
||||||
|
if (!response.ok) throw new Error('Unable to check your session.')
|
||||||
|
return response.json()
|
||||||
|
})
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useRouteContext } from '@tanstack/react-router'
|
||||||
import { useServerFn } from '@tanstack/react-start'
|
import { useServerFn } from '@tanstack/react-start'
|
||||||
|
import { useState } from 'react'
|
||||||
import { Dialog } from '#/components/dialog'
|
import { Dialog } from '#/components/dialog'
|
||||||
import { Button } from '#/components/ui/button'
|
import { Button } from '#/components/ui/button'
|
||||||
|
import { authRequest } from '#/features/auth/auth-client'
|
||||||
import { ConnectionManager } from './connection-manager'
|
import { ConnectionManager } from './connection-manager'
|
||||||
import { loadConnections } from './server-functions'
|
import { loadConnections } from './server-functions'
|
||||||
|
|
||||||
@@ -12,6 +15,20 @@ export function ConnectionManagerDialog({
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
hasTemporaryDecks?: boolean
|
hasTemporaryDecks?: boolean
|
||||||
}) {
|
}) {
|
||||||
|
const { auth } = useRouteContext({ from: '__root__' })
|
||||||
|
const [signingOut, setSigningOut] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
async function logout() {
|
||||||
|
setSigningOut(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await authRequest({ action: 'logout' })
|
||||||
|
window.location.assign('/login')
|
||||||
|
} catch {
|
||||||
|
setError('Unable to sign out. Please try again.')
|
||||||
|
setSigningOut(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
const fetchConnections = useServerFn(loadConnections)
|
const fetchConnections = useServerFn(loadConnections)
|
||||||
const connections = useQuery({
|
const connections = useQuery({
|
||||||
queryKey: ['connections'],
|
queryKey: ['connections'],
|
||||||
@@ -25,6 +42,22 @@ export function ConnectionManagerDialog({
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
className="sm:max-w-2xl"
|
className="sm:max-w-2xl"
|
||||||
>
|
>
|
||||||
|
<div className="mb-5 flex items-center justify-between gap-4 border-b pb-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-medium">{auth.owner?.name}</p>
|
||||||
|
<p className="truncate text-sm text-muted-foreground">
|
||||||
|
{auth.owner?.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={signingOut}
|
||||||
|
onClick={() => void logout()}
|
||||||
|
>
|
||||||
|
{signingOut ? 'Signing out…' : 'Sign out'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{error && <p role="alert">{error}</p>}
|
||||||
{connections.isPending ? (
|
{connections.isPending ? (
|
||||||
<p role="status">Loading connected accounts…</p>
|
<p role="status">Loading connected accounts…</p>
|
||||||
) : connections.isError ? (
|
) : connections.isError ? (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Link, useNavigate } from '@tanstack/react-router'
|
import { Link, useNavigate, useRouteContext } from '@tanstack/react-router'
|
||||||
import {
|
import {
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
@@ -32,6 +32,7 @@ import { type HomeAction, interpretAction } from './interpret-action'
|
|||||||
import './home.css'
|
import './home.css'
|
||||||
|
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
|
const { auth } = useRouteContext({ from: '__root__' })
|
||||||
const {
|
const {
|
||||||
tasks,
|
tasks,
|
||||||
setTasks,
|
setTasks,
|
||||||
@@ -175,7 +176,7 @@ export function HomePage() {
|
|||||||
<div className="home-content">
|
<div className="home-content">
|
||||||
<header className="home-greeting">
|
<header className="home-greeting">
|
||||||
<p className="text-sm text-muted-foreground">Today</p>
|
<p className="text-sm text-muted-foreground">Today</p>
|
||||||
<h1>Good morning, Yuta</h1>
|
<h1>Good morning, {auth.owner?.name}</h1>
|
||||||
<DailyBrief onOpenContact={setContactId} />
|
<DailyBrief onOpenContact={setContactId} />
|
||||||
</header>
|
</header>
|
||||||
<form
|
<form
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ type Snapshot = { configured: boolean; run: ResearchRun | null }
|
|||||||
type Subscribe = (listener: (snapshot: Snapshot) => void) => () => void
|
type Subscribe = (listener: (snapshot: Snapshot) => void) => () => void
|
||||||
|
|
||||||
/** Subscribe to current state and subsequent changes; disconnect only this viewer. */
|
/** Subscribe to current state and subsequent changes; disconnect only this viewer. */
|
||||||
export function researchEvents(request: Request, subscribe: Subscribe) {
|
export function researchEvents(
|
||||||
|
request: Request,
|
||||||
|
subscribe: Subscribe,
|
||||||
|
isAuthorized: () => boolean = () => true,
|
||||||
|
) {
|
||||||
const encoder = new TextEncoder()
|
const encoder = new TextEncoder()
|
||||||
let dispose = () => {}
|
let dispose = () => {}
|
||||||
let flushPending = () => {}
|
let flushPending = () => {}
|
||||||
@@ -14,7 +18,15 @@ export function researchEvents(request: Request, subscribe: Subscribe) {
|
|||||||
let closed = false
|
let closed = false
|
||||||
let unsubscribe = () => {}
|
let unsubscribe = () => {}
|
||||||
let heartbeat: ReturnType<typeof setInterval> | undefined
|
let heartbeat: ReturnType<typeof setInterval> | undefined
|
||||||
|
const checkSession = () => {
|
||||||
|
if (closed) return false
|
||||||
|
if (isAuthorized()) return true
|
||||||
|
dispose()
|
||||||
|
controller.close()
|
||||||
|
return false
|
||||||
|
}
|
||||||
const flush = () => {
|
const flush = () => {
|
||||||
|
if (!checkSession()) return
|
||||||
if (
|
if (
|
||||||
!closed &&
|
!closed &&
|
||||||
pending !== undefined &&
|
pending !== undefined &&
|
||||||
@@ -46,7 +58,12 @@ export function researchEvents(request: Request, subscribe: Subscribe) {
|
|||||||
pending = `data: ${JSON.stringify(snapshot)}\n\n`
|
pending = `data: ${JSON.stringify(snapshot)}\n\n`
|
||||||
flush()
|
flush()
|
||||||
})
|
})
|
||||||
|
if (closed) {
|
||||||
|
unsubscribe()
|
||||||
|
return
|
||||||
|
}
|
||||||
heartbeat = setInterval(() => {
|
heartbeat = setInterval(() => {
|
||||||
|
if (!checkSession()) return
|
||||||
if (
|
if (
|
||||||
!closed &&
|
!closed &&
|
||||||
pending === undefined &&
|
pending === undefined &&
|
||||||
|
|||||||
@@ -81,3 +81,25 @@ it('bounds a slow viewer buffer while retaining the latest state', async () => {
|
|||||||
)
|
)
|
||||||
await reader.cancel()
|
await reader.cancel()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('stops sending updates when its login session is revoked', async () => {
|
||||||
|
let authorized = true
|
||||||
|
let emit = (_value: { configured: boolean; run: null }) => {}
|
||||||
|
const unsubscribe = vi.fn()
|
||||||
|
const response = researchEvents(
|
||||||
|
new Request('http://127.0.0.1/events'),
|
||||||
|
(listener) => {
|
||||||
|
emit = listener
|
||||||
|
listener({ configured: false, run: null })
|
||||||
|
return unsubscribe
|
||||||
|
},
|
||||||
|
() => authorized,
|
||||||
|
)
|
||||||
|
const reader = response.body?.getReader()
|
||||||
|
expect.assert.isDefined(reader)
|
||||||
|
await reader.read()
|
||||||
|
authorized = false
|
||||||
|
emit({ configured: true, run: null })
|
||||||
|
expect((await reader.read()).done).toBe(true)
|
||||||
|
expect(unsubscribe).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|||||||
@@ -42,4 +42,14 @@ export const migrations = [
|
|||||||
folderMillis: 1790242505709,
|
folderMillis: 1790242505709,
|
||||||
hash: '5b74e60a806df54dc3c6897aa84305b80a3b097dd70cd6cf1feebcfd3b72a135',
|
hash: '5b74e60a806df54dc3c6897aa84305b80a3b097dd70cd6cf1feebcfd3b72a135',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
sql: [
|
||||||
|
'CREATE TABLE `auth_sessions` (\n\t`token_hash` text PRIMARY KEY NOT NULL,\n\t`owner_id` integer NOT NULL,\n\t`expires_at` integer NOT NULL,\n\tFOREIGN KEY (`owner_id`) REFERENCES `workspace_owner`(`id`) ON UPDATE no action ON DELETE cascade\n);\n',
|
||||||
|
'\nCREATE TABLE `auth_throttle` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`attempts` integer NOT NULL,\n\t`reset_at` integer NOT NULL,\n\tCONSTRAINT "auth_throttle_singleton" CHECK("auth_throttle"."id" = 1)\n);\n',
|
||||||
|
'\nCREATE TABLE `workspace_owner` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`email` text NOT NULL,\n\t`name` text NOT NULL,\n\t`password_hash` text NOT NULL,\n\t`onboarding_completed_at` integer,\n\t`created_at` integer NOT NULL,\n\tCONSTRAINT "workspace_owner_singleton" CHECK("workspace_owner"."id" = 1)\n);\n',
|
||||||
|
],
|
||||||
|
bps: true,
|
||||||
|
folderMillis: 1790589494499,
|
||||||
|
hash: 'c2093b5a2e4f84ffa333dff896fc8a7bf86015ce84ae562765b525b763af749e',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -146,3 +146,34 @@ export const researchState = sqliteTable(
|
|||||||
},
|
},
|
||||||
(table) => [check('research_state_singleton', sql`${table.id} = 1`)],
|
(table) => [check('research_state_singleton', sql`${table.id} = 1`)],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const workspaceOwner = sqliteTable(
|
||||||
|
'workspace_owner',
|
||||||
|
{
|
||||||
|
id: integer('id').primaryKey(),
|
||||||
|
email: text('email').notNull(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
passwordHash: text('password_hash').notNull(),
|
||||||
|
onboardingCompletedAt: integer('onboarding_completed_at'),
|
||||||
|
createdAt: integer('created_at').notNull(),
|
||||||
|
},
|
||||||
|
(table) => [check('workspace_owner_singleton', sql`${table.id} = 1`)],
|
||||||
|
)
|
||||||
|
|
||||||
|
export const authSessions = sqliteTable('auth_sessions', {
|
||||||
|
tokenHash: text('token_hash').primaryKey(),
|
||||||
|
ownerId: integer('owner_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => workspaceOwner.id, { onDelete: 'cascade' }),
|
||||||
|
expiresAt: integer('expires_at').notNull(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const authThrottle = sqliteTable(
|
||||||
|
'auth_throttle',
|
||||||
|
{
|
||||||
|
id: integer('id').primaryKey(),
|
||||||
|
attempts: integer('attempts').notNull(),
|
||||||
|
resetAt: integer('reset_at').notNull(),
|
||||||
|
},
|
||||||
|
(table) => [check('auth_throttle_singleton', sql`${table.id} = 1`)],
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useRouteContext } from '@tanstack/react-router'
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
Bell,
|
Bell,
|
||||||
@@ -52,6 +53,7 @@ const nav = [
|
|||||||
{ name: 'Profile', icon: UserRound },
|
{ name: 'Profile', icon: UserRound },
|
||||||
]
|
]
|
||||||
export function VitalsPage() {
|
export function VitalsPage() {
|
||||||
|
const { auth } = useRouteContext({ from: '__root__' })
|
||||||
const [section, setSection] = useState('Body')
|
const [section, setSection] = useState('Body')
|
||||||
const [mobileMenu, setMobileMenu] = useState(false)
|
const [mobileMenu, setMobileMenu] = useState(false)
|
||||||
const [detail, setDetail] = useState<string>()
|
const [detail, setDetail] = useState<string>()
|
||||||
@@ -141,7 +143,10 @@ export function VitalsPage() {
|
|||||||
className="vital-profile"
|
className="vital-profile"
|
||||||
onClick={() => setDetail('Profile')}
|
onClick={() => setDetail('Profile')}
|
||||||
>
|
>
|
||||||
<span className="vital-avatar">Y</span>Yuta
|
<span className="vital-avatar">
|
||||||
|
{auth.owner?.name.slice(0, 1).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
{auth.owner?.name}
|
||||||
<ChevronDown />
|
<ChevronDown />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,10 +11,14 @@
|
|||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
import { Route as VitalsRouteImport } from './routes/vitals'
|
import { Route as VitalsRouteImport } from './routes/vitals'
|
||||||
import { Route as SupportRouteImport } from './routes/support'
|
import { Route as SupportRouteImport } from './routes/support'
|
||||||
|
import { Route as SetupRouteImport } from './routes/setup'
|
||||||
|
import { Route as OnboardingRouteImport } from './routes/onboarding'
|
||||||
|
import { Route as LoginRouteImport } from './routes/login'
|
||||||
import { Route as JournalRouteImport } from './routes/journal'
|
import { Route as JournalRouteImport } from './routes/journal'
|
||||||
import { Route as InboxRouteImport } from './routes/inbox'
|
import { Route as InboxRouteImport } from './routes/inbox'
|
||||||
import { Route as DeckRouteImport } from './routes/deck'
|
import { Route as DeckRouteImport } from './routes/deck'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
|
import { Route as ApiAuthRouteImport } from './routes/api/auth'
|
||||||
import { Route as OauthMastodonCallbackRouteImport } from './routes/oauth/mastodon/callback'
|
import { Route as OauthMastodonCallbackRouteImport } from './routes/oauth/mastodon/callback'
|
||||||
import { Route as ApiResearchEventsRouteImport } from './routes/api/research/events'
|
import { Route as ApiResearchEventsRouteImport } from './routes/api/research/events'
|
||||||
|
|
||||||
@@ -28,6 +32,21 @@ const SupportRoute = SupportRouteImport.update({
|
|||||||
path: '/support',
|
path: '/support',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const SetupRoute = SetupRouteImport.update({
|
||||||
|
id: '/setup',
|
||||||
|
path: '/setup',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const OnboardingRoute = OnboardingRouteImport.update({
|
||||||
|
id: '/onboarding',
|
||||||
|
path: '/onboarding',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const LoginRoute = LoginRouteImport.update({
|
||||||
|
id: '/login',
|
||||||
|
path: '/login',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const JournalRoute = JournalRouteImport.update({
|
const JournalRoute = JournalRouteImport.update({
|
||||||
id: '/journal',
|
id: '/journal',
|
||||||
path: '/journal',
|
path: '/journal',
|
||||||
@@ -48,6 +67,11 @@ const IndexRoute = IndexRouteImport.update({
|
|||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ApiAuthRoute = ApiAuthRouteImport.update({
|
||||||
|
id: '/api/auth',
|
||||||
|
path: '/api/auth',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const OauthMastodonCallbackRoute = OauthMastodonCallbackRouteImport.update({
|
const OauthMastodonCallbackRoute = OauthMastodonCallbackRouteImport.update({
|
||||||
id: '/oauth/mastodon/callback',
|
id: '/oauth/mastodon/callback',
|
||||||
path: '/oauth/mastodon/callback',
|
path: '/oauth/mastodon/callback',
|
||||||
@@ -64,8 +88,12 @@ export interface FileRoutesByFullPath {
|
|||||||
'/deck': typeof DeckRoute
|
'/deck': typeof DeckRoute
|
||||||
'/inbox': typeof InboxRoute
|
'/inbox': typeof InboxRoute
|
||||||
'/journal': typeof JournalRoute
|
'/journal': typeof JournalRoute
|
||||||
|
'/login': typeof LoginRoute
|
||||||
|
'/onboarding': typeof OnboardingRoute
|
||||||
|
'/setup': typeof SetupRoute
|
||||||
'/support': typeof SupportRoute
|
'/support': typeof SupportRoute
|
||||||
'/vitals': typeof VitalsRoute
|
'/vitals': typeof VitalsRoute
|
||||||
|
'/api/auth': typeof ApiAuthRoute
|
||||||
'/api/research/events': typeof ApiResearchEventsRoute
|
'/api/research/events': typeof ApiResearchEventsRoute
|
||||||
'/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute
|
'/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute
|
||||||
}
|
}
|
||||||
@@ -74,8 +102,12 @@ export interface FileRoutesByTo {
|
|||||||
'/deck': typeof DeckRoute
|
'/deck': typeof DeckRoute
|
||||||
'/inbox': typeof InboxRoute
|
'/inbox': typeof InboxRoute
|
||||||
'/journal': typeof JournalRoute
|
'/journal': typeof JournalRoute
|
||||||
|
'/login': typeof LoginRoute
|
||||||
|
'/onboarding': typeof OnboardingRoute
|
||||||
|
'/setup': typeof SetupRoute
|
||||||
'/support': typeof SupportRoute
|
'/support': typeof SupportRoute
|
||||||
'/vitals': typeof VitalsRoute
|
'/vitals': typeof VitalsRoute
|
||||||
|
'/api/auth': typeof ApiAuthRoute
|
||||||
'/api/research/events': typeof ApiResearchEventsRoute
|
'/api/research/events': typeof ApiResearchEventsRoute
|
||||||
'/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute
|
'/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute
|
||||||
}
|
}
|
||||||
@@ -85,8 +117,12 @@ export interface FileRoutesById {
|
|||||||
'/deck': typeof DeckRoute
|
'/deck': typeof DeckRoute
|
||||||
'/inbox': typeof InboxRoute
|
'/inbox': typeof InboxRoute
|
||||||
'/journal': typeof JournalRoute
|
'/journal': typeof JournalRoute
|
||||||
|
'/login': typeof LoginRoute
|
||||||
|
'/onboarding': typeof OnboardingRoute
|
||||||
|
'/setup': typeof SetupRoute
|
||||||
'/support': typeof SupportRoute
|
'/support': typeof SupportRoute
|
||||||
'/vitals': typeof VitalsRoute
|
'/vitals': typeof VitalsRoute
|
||||||
|
'/api/auth': typeof ApiAuthRoute
|
||||||
'/api/research/events': typeof ApiResearchEventsRoute
|
'/api/research/events': typeof ApiResearchEventsRoute
|
||||||
'/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute
|
'/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute
|
||||||
}
|
}
|
||||||
@@ -97,8 +133,12 @@ export interface FileRouteTypes {
|
|||||||
| '/deck'
|
| '/deck'
|
||||||
| '/inbox'
|
| '/inbox'
|
||||||
| '/journal'
|
| '/journal'
|
||||||
|
| '/login'
|
||||||
|
| '/onboarding'
|
||||||
|
| '/setup'
|
||||||
| '/support'
|
| '/support'
|
||||||
| '/vitals'
|
| '/vitals'
|
||||||
|
| '/api/auth'
|
||||||
| '/api/research/events'
|
| '/api/research/events'
|
||||||
| '/oauth/mastodon/callback'
|
| '/oauth/mastodon/callback'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
@@ -107,8 +147,12 @@ export interface FileRouteTypes {
|
|||||||
| '/deck'
|
| '/deck'
|
||||||
| '/inbox'
|
| '/inbox'
|
||||||
| '/journal'
|
| '/journal'
|
||||||
|
| '/login'
|
||||||
|
| '/onboarding'
|
||||||
|
| '/setup'
|
||||||
| '/support'
|
| '/support'
|
||||||
| '/vitals'
|
| '/vitals'
|
||||||
|
| '/api/auth'
|
||||||
| '/api/research/events'
|
| '/api/research/events'
|
||||||
| '/oauth/mastodon/callback'
|
| '/oauth/mastodon/callback'
|
||||||
id:
|
id:
|
||||||
@@ -117,8 +161,12 @@ export interface FileRouteTypes {
|
|||||||
| '/deck'
|
| '/deck'
|
||||||
| '/inbox'
|
| '/inbox'
|
||||||
| '/journal'
|
| '/journal'
|
||||||
|
| '/login'
|
||||||
|
| '/onboarding'
|
||||||
|
| '/setup'
|
||||||
| '/support'
|
| '/support'
|
||||||
| '/vitals'
|
| '/vitals'
|
||||||
|
| '/api/auth'
|
||||||
| '/api/research/events'
|
| '/api/research/events'
|
||||||
| '/oauth/mastodon/callback'
|
| '/oauth/mastodon/callback'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
@@ -128,8 +176,12 @@ export interface RootRouteChildren {
|
|||||||
DeckRoute: typeof DeckRoute
|
DeckRoute: typeof DeckRoute
|
||||||
InboxRoute: typeof InboxRoute
|
InboxRoute: typeof InboxRoute
|
||||||
JournalRoute: typeof JournalRoute
|
JournalRoute: typeof JournalRoute
|
||||||
|
LoginRoute: typeof LoginRoute
|
||||||
|
OnboardingRoute: typeof OnboardingRoute
|
||||||
|
SetupRoute: typeof SetupRoute
|
||||||
SupportRoute: typeof SupportRoute
|
SupportRoute: typeof SupportRoute
|
||||||
VitalsRoute: typeof VitalsRoute
|
VitalsRoute: typeof VitalsRoute
|
||||||
|
ApiAuthRoute: typeof ApiAuthRoute
|
||||||
ApiResearchEventsRoute: typeof ApiResearchEventsRoute
|
ApiResearchEventsRoute: typeof ApiResearchEventsRoute
|
||||||
OauthMastodonCallbackRoute: typeof OauthMastodonCallbackRoute
|
OauthMastodonCallbackRoute: typeof OauthMastodonCallbackRoute
|
||||||
}
|
}
|
||||||
@@ -150,6 +202,27 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof SupportRouteImport
|
preLoaderRoute: typeof SupportRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/setup': {
|
||||||
|
id: '/setup'
|
||||||
|
path: '/setup'
|
||||||
|
fullPath: '/setup'
|
||||||
|
preLoaderRoute: typeof SetupRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/onboarding': {
|
||||||
|
id: '/onboarding'
|
||||||
|
path: '/onboarding'
|
||||||
|
fullPath: '/onboarding'
|
||||||
|
preLoaderRoute: typeof OnboardingRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/login': {
|
||||||
|
id: '/login'
|
||||||
|
path: '/login'
|
||||||
|
fullPath: '/login'
|
||||||
|
preLoaderRoute: typeof LoginRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/journal': {
|
'/journal': {
|
||||||
id: '/journal'
|
id: '/journal'
|
||||||
path: '/journal'
|
path: '/journal'
|
||||||
@@ -178,6 +251,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof IndexRouteImport
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/api/auth': {
|
||||||
|
id: '/api/auth'
|
||||||
|
path: '/api/auth'
|
||||||
|
fullPath: '/api/auth'
|
||||||
|
preLoaderRoute: typeof ApiAuthRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/oauth/mastodon/callback': {
|
'/oauth/mastodon/callback': {
|
||||||
id: '/oauth/mastodon/callback'
|
id: '/oauth/mastodon/callback'
|
||||||
path: '/oauth/mastodon/callback'
|
path: '/oauth/mastodon/callback'
|
||||||
@@ -200,8 +280,12 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
DeckRoute: DeckRoute,
|
DeckRoute: DeckRoute,
|
||||||
InboxRoute: InboxRoute,
|
InboxRoute: InboxRoute,
|
||||||
JournalRoute: JournalRoute,
|
JournalRoute: JournalRoute,
|
||||||
|
LoginRoute: LoginRoute,
|
||||||
|
OnboardingRoute: OnboardingRoute,
|
||||||
|
SetupRoute: SetupRoute,
|
||||||
SupportRoute: SupportRoute,
|
SupportRoute: SupportRoute,
|
||||||
VitalsRoute: VitalsRoute,
|
VitalsRoute: VitalsRoute,
|
||||||
|
ApiAuthRoute: ApiAuthRoute,
|
||||||
ApiResearchEventsRoute: ApiResearchEventsRoute,
|
ApiResearchEventsRoute: ApiResearchEventsRoute,
|
||||||
OauthMastodonCallbackRoute: OauthMastodonCallbackRoute,
|
OauthMastodonCallbackRoute: OauthMastodonCallbackRoute,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,36 @@ import {
|
|||||||
createRootRouteWithContext,
|
createRootRouteWithContext,
|
||||||
HeadContent,
|
HeadContent,
|
||||||
Outlet,
|
Outlet,
|
||||||
|
redirect,
|
||||||
Scripts,
|
Scripts,
|
||||||
} from '@tanstack/react-router'
|
} from '@tanstack/react-router'
|
||||||
|
import { loadAuthState } from '#/features/auth/session'
|
||||||
import { WorkspaceStateProvider } from '#/features/workspace/workspace-state'
|
import { WorkspaceStateProvider } from '#/features/workspace/workspace-state'
|
||||||
import appCss from '../styles.css?url'
|
import appCss from '../styles.css?url'
|
||||||
|
|
||||||
type RouterContext = { queryClient: QueryClient }
|
type RouterContext = { queryClient: QueryClient }
|
||||||
|
|
||||||
export const Route = createRootRouteWithContext<RouterContext>()({
|
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||||
|
beforeLoad: async ({ location }) => {
|
||||||
|
const auth = await loadAuthState()
|
||||||
|
const path = location.pathname
|
||||||
|
const entry = path === '/login' || path === '/setup'
|
||||||
|
if (!auth.owner && auth.needsSetup && path === '/login')
|
||||||
|
throw redirect({ to: '/setup' })
|
||||||
|
if (!auth.owner && !entry)
|
||||||
|
throw redirect({ to: auth.needsSetup ? '/setup' : '/login' })
|
||||||
|
if (!auth.owner && path === '/setup' && !auth.needsSetup)
|
||||||
|
throw redirect({ to: '/login' })
|
||||||
|
if (
|
||||||
|
auth.owner &&
|
||||||
|
!auth.owner.onboardingCompletedAt &&
|
||||||
|
path !== '/onboarding'
|
||||||
|
)
|
||||||
|
throw redirect({ to: '/onboarding' })
|
||||||
|
if (auth.owner?.onboardingCompletedAt && (entry || path === '/onboarding'))
|
||||||
|
throw redirect({ to: '/' })
|
||||||
|
return { auth }
|
||||||
|
},
|
||||||
head: () => ({
|
head: () => ({
|
||||||
meta: [
|
meta: [
|
||||||
{ charSet: 'utf-8' },
|
{ charSet: 'utf-8' },
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
async function handle({ request }: { request: Request }) {
|
||||||
|
const { authEndpoint } = await import('../../features/auth/http.server')
|
||||||
|
return authEndpoint(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/api/auth')({
|
||||||
|
server: { handlers: { GET: handle, POST: handle } },
|
||||||
|
})
|
||||||
@@ -8,7 +8,16 @@ export const Route = createFileRoute('/api/research/events')({
|
|||||||
import('../../../features/research/events.server'),
|
import('../../../features/research/events.server'),
|
||||||
import('../../../features/research/runner.server'),
|
import('../../../features/research/runner.server'),
|
||||||
])
|
])
|
||||||
return researchEvents(request, researchService().subscribe)
|
const { getSession } = await import(
|
||||||
|
'../../../features/auth/auth.server'
|
||||||
|
)
|
||||||
|
const { sessionToken } = await import(
|
||||||
|
'../../../features/auth/http.server'
|
||||||
|
)
|
||||||
|
const token = sessionToken(request)
|
||||||
|
return researchEvents(request, researchService().subscribe, () =>
|
||||||
|
Boolean(getSession(token)?.onboardingCompletedAt),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { LoginPage } from '#/features/auth/auth-page'
|
||||||
|
export const Route = createFileRoute('/login')({ component: LoginPage })
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { OnboardingPage } from '#/features/auth/onboarding-page'
|
||||||
|
export const Route = createFileRoute('/onboarding')({
|
||||||
|
component: OnboardingPage,
|
||||||
|
})
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { SetupPage } from '#/features/auth/auth-page'
|
||||||
|
export const Route = createFileRoute('/setup')({ component: SetupPage })
|
||||||
+8
-1
@@ -15,6 +15,13 @@ const csrf = createCsrfMiddleware({
|
|||||||
filter: (context) => context.handlerType === 'serverFn',
|
filter: (context) => context.handlerType === 'serverFn',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const appSession = createMiddleware().server(async ({ request, next }) => {
|
||||||
|
const { checkSessionAccess } = await import('./features/auth/gate.server')
|
||||||
|
const { setResponseHeader } = await import('@tanstack/react-start/server')
|
||||||
|
setResponseHeader('Cache-Control', 'no-store')
|
||||||
|
return checkSessionAccess(request) ?? next()
|
||||||
|
})
|
||||||
|
|
||||||
const storage = createMiddleware().server(async ({ next }) => {
|
const storage = createMiddleware().server(async ({ next }) => {
|
||||||
const { getDatabase } = await import('./features/storage/database.server')
|
const { getDatabase } = await import('./features/storage/database.server')
|
||||||
getDatabase()
|
getDatabase()
|
||||||
@@ -22,5 +29,5 @@ const storage = createMiddleware().server(async ({ next }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const startInstance = createStart(() => ({
|
export const startInstance = createStart(() => ({
|
||||||
requestMiddleware: [ownerAccess, csrf, storage],
|
requestMiddleware: [ownerAccess, csrf, storage, appSession],
|
||||||
}))
|
}))
|
||||||
|
|||||||
Reference in New Issue
Block a user