Merge pull request #125 from trails-cool/openspec-account-settings
Add account-settings OpenSpec change
This commit is contained in:
commit
881374008f
14 changed files with 546 additions and 33 deletions
|
|
@ -144,6 +144,36 @@ export async function addPasskeyFinish(
|
|||
});
|
||||
}
|
||||
|
||||
// --- Registration via Magic Link (no passkey) ---
|
||||
|
||||
export async function registerWithMagicLink(email: string, username: string): Promise<string> {
|
||||
const db = getDb();
|
||||
|
||||
const [existingEmail] = await db.select().from(users).where(eq(users.email, email));
|
||||
if (existingEmail) throw new Error("Email already in use");
|
||||
|
||||
const [existingUsername] = await db.select().from(users).where(eq(users.username, username));
|
||||
if (existingUsername) throw new Error("Username already taken");
|
||||
|
||||
const userId = randomUUID();
|
||||
const domain = process.env.DOMAIN ?? "localhost";
|
||||
|
||||
await db.insert(users).values({ id: userId, email, username, domain });
|
||||
|
||||
// Create magic token for verification
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
const expiresAt = new Date(Date.now() + 15 * 60 * 1000);
|
||||
|
||||
await db.insert(magicTokens).values({
|
||||
id: randomUUID(),
|
||||
email,
|
||||
token,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
// --- Passkey Login ---
|
||||
|
||||
export async function startAuthentication() {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import { data } from "react-router";
|
||||
import type { Route } from "./+types/api.auth.register";
|
||||
import { startRegistration, finishRegistration, createSession, addPasskeyStart, addPasskeyFinish } from "~/lib/auth.server";
|
||||
import { sendWelcome } from "~/lib/email.server";
|
||||
import { startRegistration, finishRegistration, createSession, addPasskeyStart, addPasskeyFinish, registerWithMagicLink } from "~/lib/auth.server";
|
||||
import { sendWelcome, sendMagicLink } from "~/lib/email.server";
|
||||
import { logger } from "~/lib/logger.server";
|
||||
|
||||
export async function action({ request }: Route.ActionArgs) {
|
||||
const body = await request.json();
|
||||
const { step, email, username, response, challenge, userId } = body;
|
||||
const origin = process.env.ORIGIN ?? `http://localhost:3000`;
|
||||
|
||||
try {
|
||||
if (step === "start") {
|
||||
|
|
@ -17,13 +18,25 @@ export async function action({ request }: Route.ActionArgs) {
|
|||
if (step === "finish") {
|
||||
const newUserId = await finishRegistration(userId, email, username, response, challenge);
|
||||
const cookie = await createSession(newUserId, request);
|
||||
// Send welcome email (fire-and-forget — don't block registration on email)
|
||||
sendWelcome(email, username).catch((err) =>
|
||||
logger.error({ err }, "Failed to send welcome email"),
|
||||
);
|
||||
return data({ step: "done" }, { headers: { "Set-Cookie": cookie } });
|
||||
}
|
||||
|
||||
if (step === "register-magic-link") {
|
||||
const token = await registerWithMagicLink(email, username);
|
||||
const link = `${origin}/auth/verify?token=${token}`;
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
return data({ step: "magic-link-sent", devLink: link });
|
||||
}
|
||||
await sendMagicLink(email, link);
|
||||
sendWelcome(email, username).catch((err) =>
|
||||
logger.error({ err }, "Failed to send welcome email"),
|
||||
);
|
||||
return data({ step: "magic-link-sent" });
|
||||
}
|
||||
|
||||
if (step === "add-passkey") {
|
||||
const options = await addPasskeyStart(userId);
|
||||
return data({ step: "challenge", options });
|
||||
|
|
|
|||
|
|
@ -1,9 +1,18 @@
|
|||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function LoginPage() {
|
||||
const { t } = useTranslation("journal");
|
||||
const [supportsPasskey, setSupportsPasskey] = useState(true);
|
||||
const [mode, setMode] = useState<"passkey" | "magic-link">("passkey");
|
||||
|
||||
useEffect(() => {
|
||||
import("@simplewebauthn/browser").then(({ browserSupportsWebAuthn }) => {
|
||||
const supported = browserSupportsWebAuthn();
|
||||
setSupportsPasskey(supported);
|
||||
if (!supported) setMode("magic-link");
|
||||
});
|
||||
}, []);
|
||||
const [email, setEmail] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
|
@ -90,7 +99,7 @@ export default function LoginPage() {
|
|||
<div className="mx-auto max-w-md px-4 py-16">
|
||||
<h1 className="text-2xl font-bold text-gray-900">{t("auth.login")}</h1>
|
||||
|
||||
{mode === "passkey" && (
|
||||
{mode === "passkey" && supportsPasskey && (
|
||||
<div className="mt-8">
|
||||
<button
|
||||
onClick={handlePasskeyLogin}
|
||||
|
|
@ -138,15 +147,17 @@ export default function LoginPage() {
|
|||
{loading ? t("auth.sending") : t("auth.sendMagicLink")}
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("passkey")}
|
||||
className="text-sm text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
{t("auth.backToPasskey")}
|
||||
</button>
|
||||
</div>
|
||||
{supportsPasskey && (
|
||||
<div className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("passkey")}
|
||||
className="text-sm text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
{t("auth.backToPasskey")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function RegisterPage() {
|
||||
|
|
@ -7,14 +7,21 @@ export default function RegisterPage() {
|
|||
const [username, setUsername] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [supportsPasskey, setSupportsPasskey] = useState<boolean | null>(null);
|
||||
const [magicLinkSent, setMagicLinkSent] = useState(false);
|
||||
|
||||
const handleRegister = async (e: React.FormEvent) => {
|
||||
useEffect(() => {
|
||||
import("@simplewebauthn/browser").then(({ browserSupportsWebAuthn }) => {
|
||||
setSupportsPasskey(browserSupportsWebAuthn());
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleRegisterPasskey = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Step 1: Get registration options from server
|
||||
const startResp = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
|
@ -28,11 +35,9 @@ export default function RegisterPage() {
|
|||
return;
|
||||
}
|
||||
|
||||
// Step 2: Create passkey via browser
|
||||
const { startRegistration: startWebAuthn } = await import("@simplewebauthn/browser");
|
||||
const webAuthnResp = await startWebAuthn(startData.options);
|
||||
|
||||
// Step 3: Send response to server
|
||||
const finishResp = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
|
@ -59,6 +64,49 @@ export default function RegisterPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleRegisterMagicLink = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const resp = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ step: "register-magic-link", email, username }),
|
||||
});
|
||||
const result = await resp.json();
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
} else if (result.devLink) {
|
||||
window.location.href = result.devLink;
|
||||
} else {
|
||||
setMagicLinkSent(true);
|
||||
}
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (magicLinkSent) {
|
||||
return (
|
||||
<div className="mx-auto max-w-md px-4 py-16">
|
||||
<h1 className="text-2xl font-bold text-gray-900">{t("auth.register")}</h1>
|
||||
<div className="mt-8 rounded-md bg-green-50 p-4">
|
||||
<p className="text-sm text-green-800">
|
||||
{t("auth.checkEmail")} <strong>{email}</strong>.
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-green-600">
|
||||
{t("auth.linkExpires")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md px-4 py-16">
|
||||
<h1 className="text-2xl font-bold text-gray-900">{t("auth.register")}</h1>
|
||||
|
|
@ -66,7 +114,7 @@ export default function RegisterPage() {
|
|||
{t("auth.registerDescription")}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleRegister} className="mt-8 space-y-4">
|
||||
<form onSubmit={supportsPasskey ? handleRegisterPasskey : handleRegisterMagicLink} className="mt-8 space-y-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||
{t("auth.email")}
|
||||
|
|
@ -103,13 +151,29 @@ export default function RegisterPage() {
|
|||
<p className="text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? t("auth.creatingPasskey") : t("auth.registerWithPasskey")}
|
||||
</button>
|
||||
{supportsPasskey ? (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? t("auth.creatingPasskey") : t("auth.registerWithPasskey")}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || supportsPasskey === null}
|
||||
className="w-full rounded-md bg-gray-800 px-4 py-2 text-white hover:bg-gray-900 disabled:opacity-50"
|
||||
>
|
||||
{loading ? t("auth.sending") : t("auth.registerWithMagicLink")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{supportsPasskey === false && (
|
||||
<p className="text-sm text-gray-500">
|
||||
{t("auth.passkeyNotSupportedRegister")}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-sm text-gray-500">
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import { useState, useCallback } from "react";
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { data } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { eq, count } from "drizzle-orm";
|
||||
import type { Route } from "./+types/home";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
import { getDb } from "~/lib/db";
|
||||
import { credentials } from "@trails-cool/db/schema/journal";
|
||||
|
||||
export function meta(_args: Route.MetaArgs) {
|
||||
return [
|
||||
|
|
@ -15,18 +18,39 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||
const user = await getSessionUser(request);
|
||||
const url = new URL(request.url);
|
||||
const showAddPasskey = url.searchParams.get("add-passkey") === "1" && user !== null;
|
||||
|
||||
let passkeyCount = 0;
|
||||
if (user) {
|
||||
const db = getDb();
|
||||
const [row] = await db
|
||||
.select({ count: count() })
|
||||
.from(credentials)
|
||||
.where(eq(credentials.userId, user.id));
|
||||
passkeyCount = row?.count ?? 0;
|
||||
}
|
||||
|
||||
return data({
|
||||
user: user ? { id: user.id, username: user.username, displayName: user.displayName } : null,
|
||||
showAddPasskey,
|
||||
passkeyCount,
|
||||
});
|
||||
}
|
||||
|
||||
export default function Home({ loaderData }: Route.ComponentProps) {
|
||||
const { user, showAddPasskey } = loaderData;
|
||||
const { user, showAddPasskey, passkeyCount } = loaderData;
|
||||
const { t } = useTranslation("journal");
|
||||
const [addingPasskey, setAddingPasskey] = useState(false);
|
||||
const [passkeyDone, setPasskeyDone] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [supportsPasskey, setSupportsPasskey] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (showAddPasskey) {
|
||||
import("@simplewebauthn/browser").then(({ browserSupportsWebAuthn }) => {
|
||||
setSupportsPasskey(browserSupportsWebAuthn());
|
||||
});
|
||||
}
|
||||
}, [showAddPasskey]);
|
||||
|
||||
const handleAddPasskey = useCallback(async () => {
|
||||
if (!user) return;
|
||||
|
|
@ -85,7 +109,7 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
|||
{t("welcome")} <a href={`/users/${user.username}`} className="text-blue-600 hover:underline">{user.displayName ?? user.username}</a>
|
||||
</p>
|
||||
|
||||
{showAddPasskey && !passkeyDone && (
|
||||
{showAddPasskey && !passkeyDone && supportsPasskey === true && (
|
||||
<div className="mt-6 rounded-md bg-blue-50 p-4">
|
||||
<p className="text-sm text-blue-800">
|
||||
{t("addPasskeyPrompt")}
|
||||
|
|
@ -101,6 +125,17 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{showAddPasskey && !passkeyDone && supportsPasskey === false && (
|
||||
<div className="mt-6 rounded-md bg-amber-50 p-4">
|
||||
<p className="text-sm text-amber-800">
|
||||
{t("addPasskeyPrompt")}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-amber-600">
|
||||
{t("auth.passkeyNotSupported")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{passkeyDone && (
|
||||
<div className="mt-6 rounded-md bg-green-50 p-4">
|
||||
<p className="text-sm text-green-800">
|
||||
|
|
@ -108,6 +143,12 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
|||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{user && passkeyCount > 0 && !showAddPasskey && (
|
||||
<p className="mt-4 text-sm text-gray-500">
|
||||
{t("passkeyStatus", { count: passkeyCount })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-8 flex gap-4">
|
||||
|
|
|
|||
2
openspec/changes/account-settings/.openspec.yaml
Normal file
2
openspec/changes/account-settings/.openspec.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-03-29
|
||||
105
openspec/changes/account-settings/design.md
Normal file
105
openspec/changes/account-settings/design.md
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
## Context
|
||||
|
||||
Users register and log in via passkeys or magic links. After that, there's no
|
||||
way to manage their account. The user profile page (`/users/:username`) is
|
||||
public-facing and read-only. Account management needs a private settings page.
|
||||
|
||||
The `users` table already has `displayName` and `bio` columns. The
|
||||
`credentials` table stores `deviceType`, `transports`, and `createdAt` per
|
||||
passkey — enough to show a meaningful list.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Single settings page with sections for profile, security, and account
|
||||
- Passkey management: list, add, delete
|
||||
- Profile editing: display name, bio
|
||||
- Email change with verification
|
||||
- Account deletion with confirmation
|
||||
|
||||
**Non-Goals:**
|
||||
- Avatar/photo upload (requires S3 — see activity-photos spec)
|
||||
- Notification preferences (no notifications yet)
|
||||
- Privacy settings (route visibility is in route-sharing spec)
|
||||
- Two-factor authentication (passkeys already provide strong auth)
|
||||
- Session management / "log out everywhere"
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1: Single settings page with sections
|
||||
|
||||
One route at `/settings` with anchor-linked sections rather than separate
|
||||
sub-pages. Keeps navigation simple and avoids route proliferation.
|
||||
|
||||
Sections:
|
||||
- **Profile** — display name, bio
|
||||
- **Security** — passkeys list, add passkey button
|
||||
- **Account** — email, delete account
|
||||
|
||||
### D2: Profile editing
|
||||
|
||||
Simple form with display name and bio fields. Submit via POST to
|
||||
`/api/settings/profile`. No real-time validation needed — just save on submit.
|
||||
|
||||
Bio is plain text, max 160 characters (like a social bio).
|
||||
|
||||
### D3: Passkey management
|
||||
|
||||
List all credentials for the current user, showing:
|
||||
- Device type (from `deviceType` column, e.g., "singleDevice" / "multiDevice")
|
||||
- Transport hints (from `transports`, e.g., "internal", "usb", "ble", "nfc")
|
||||
- Date registered (from `createdAt`)
|
||||
- Delete button (with confirmation)
|
||||
|
||||
Friendly labels derived from transports:
|
||||
- `internal` → "This device"
|
||||
- `usb` → "Security key"
|
||||
- `ble` → "Bluetooth"
|
||||
- `hybrid` → "Phone or tablet"
|
||||
- fallback → "Passkey"
|
||||
|
||||
Add passkey button reuses the existing `addPasskeyStart` / `addPasskeyFinish`
|
||||
flow from auth.server.ts.
|
||||
|
||||
Deleting the last passkey is allowed — user can still log in via magic link.
|
||||
Show a warning when deleting the last one.
|
||||
|
||||
### D4: Email change
|
||||
|
||||
Two-step flow:
|
||||
1. User enters new email → server sends magic link to the **new** email
|
||||
2. User clicks link → email is updated
|
||||
|
||||
This ensures ownership of the new address. The old email is not notified
|
||||
(simplicity; can add later if needed).
|
||||
|
||||
New server function: `initiateEmailChange(userId, newEmail)` creates a
|
||||
magic token with a `newEmail` field, sends verification to the new address.
|
||||
New verify handler recognizes email-change tokens and updates the user record.
|
||||
|
||||
### D5: Account deletion
|
||||
|
||||
- Button at bottom of settings page, styled as danger
|
||||
- Confirmation modal: "This will permanently delete your account and all your
|
||||
data. Type your username to confirm."
|
||||
- Server-side: cascading delete via DB foreign keys (credentials, magic tokens,
|
||||
routes, activities all cascade from users)
|
||||
- After deletion: destroy session, redirect to home page
|
||||
|
||||
### D6: Navigation
|
||||
|
||||
Add "Settings" link to the user dropdown/nav when logged in. Links to
|
||||
`/settings`. Must be registered in `routes.ts`.
|
||||
|
||||
### D7: Auth guard
|
||||
|
||||
Settings page requires authentication. Loader checks session and redirects to
|
||||
`/auth/login` if not logged in.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Email change without old-email notification**: Simpler but less secure.
|
||||
If an attacker has session access, they could change email silently. Mitigate:
|
||||
magic link to new address still required. Acceptable for now.
|
||||
- **Deleting last passkey**: User relies entirely on magic links. Acceptable
|
||||
since magic links are a first-class auth method.
|
||||
32
openspec/changes/account-settings/proposal.md
Normal file
32
openspec/changes/account-settings/proposal.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
## Why
|
||||
|
||||
Users have no way to manage their account after registration. There's no
|
||||
settings page to edit their profile, manage passkeys, change email, or delete
|
||||
their account. Passkey count currently shows on the home page as a stopgap,
|
||||
but it belongs in a dedicated settings area.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Account settings page** (`/settings`): central place for account management
|
||||
- **Profile editing**: update display name and bio
|
||||
- **Email change**: update email with verification via magic link
|
||||
- **Passkey management**: view registered passkeys (device type, date added),
|
||||
add new passkeys, delete existing ones
|
||||
- **Account deletion**: delete account and all associated data with confirmation
|
||||
- Move passkey status display from home page to settings
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `account-settings`: Settings page with profile, security, and account sections
|
||||
|
||||
### Modified Capabilities
|
||||
- `journal-auth`: Passkey management moves from home page prompt to settings;
|
||||
add passkey delete functionality
|
||||
|
||||
## Impact
|
||||
|
||||
- **Files**: New route `apps/journal/app/routes/settings.tsx`, API routes for
|
||||
profile update, email change, passkey delete, account delete
|
||||
- **Database**: No schema changes — uses existing users and credentials tables
|
||||
- **Routes**: Must register new routes in `apps/journal/app/routes.ts`
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Settings page access
|
||||
The Journal SHALL provide an account settings page at `/settings` accessible to authenticated users.
|
||||
|
||||
#### Scenario: Authenticated access
|
||||
- **WHEN** a logged-in user navigates to `/settings`
|
||||
- **THEN** the settings page is displayed with profile, security, and account sections
|
||||
|
||||
#### Scenario: Unauthenticated access
|
||||
- **WHEN** an unauthenticated user navigates to `/settings`
|
||||
- **THEN** they are redirected to `/auth/login`
|
||||
|
||||
### Requirement: Profile editing
|
||||
The settings page SHALL allow users to edit their display name and bio.
|
||||
|
||||
#### Scenario: Update display name
|
||||
- **WHEN** a user changes their display name and submits
|
||||
- **THEN** the display name is updated and reflected on their public profile
|
||||
|
||||
#### Scenario: Update bio
|
||||
- **WHEN** a user enters a bio (max 160 characters) and submits
|
||||
- **THEN** the bio is saved and visible on their public profile
|
||||
|
||||
#### Scenario: Empty fields
|
||||
- **WHEN** a user clears their display name
|
||||
- **THEN** their username is used as the display name fallback
|
||||
|
||||
### Requirement: Passkey management
|
||||
The settings page SHALL list all registered passkeys and allow adding or deleting them.
|
||||
|
||||
#### Scenario: View passkeys
|
||||
- **WHEN** a user visits the security section
|
||||
- **THEN** they see a list of their passkeys with device type, transport label, and registration date
|
||||
|
||||
#### Scenario: Add passkey
|
||||
- **WHEN** a user clicks "Add passkey" on a browser that supports WebAuthn
|
||||
- **THEN** the browser passkey creation prompt appears and the new passkey is added to the list
|
||||
|
||||
#### Scenario: Add passkey unsupported browser
|
||||
- **WHEN** a user visits the security section on a browser without WebAuthn
|
||||
- **THEN** the "Add passkey" button is disabled with a message explaining the browser limitation
|
||||
|
||||
#### Scenario: Delete passkey
|
||||
- **WHEN** a user clicks delete on a passkey and confirms
|
||||
- **THEN** the passkey is removed and can no longer be used for login
|
||||
|
||||
#### Scenario: Delete last passkey
|
||||
- **WHEN** a user deletes their only passkey
|
||||
- **THEN** a warning is shown that they will need to use magic links to sign in, and the deletion proceeds after confirmation
|
||||
|
||||
### Requirement: Email change
|
||||
The settings page SHALL allow users to change their email address with verification.
|
||||
|
||||
#### Scenario: Initiate email change
|
||||
- **WHEN** a user enters a new email and submits
|
||||
- **THEN** a verification link is sent to the new email address
|
||||
|
||||
#### Scenario: Verify new email
|
||||
- **WHEN** the user clicks the verification link
|
||||
- **THEN** their email is updated to the new address
|
||||
|
||||
#### Scenario: Duplicate email
|
||||
- **WHEN** a user enters an email already in use by another account
|
||||
- **THEN** an error is shown indicating the email is taken
|
||||
|
||||
### Requirement: Account deletion
|
||||
The settings page SHALL allow users to permanently delete their account.
|
||||
|
||||
#### Scenario: Delete account
|
||||
- **WHEN** a user clicks "Delete account" and types their username to confirm
|
||||
- **THEN** their account and all associated data are permanently deleted and they are logged out
|
||||
|
||||
#### Scenario: Cancel deletion
|
||||
- **WHEN** a user opens the delete confirmation but does not confirm
|
||||
- **THEN** no data is deleted and they remain on the settings page
|
||||
|
||||
### Requirement: Settings navigation
|
||||
The Journal navigation SHALL include a link to the settings page for authenticated users.
|
||||
|
||||
#### Scenario: Settings link visible
|
||||
- **WHEN** a logged-in user views the navigation
|
||||
- **THEN** a "Settings" link is present that navigates to `/settings`
|
||||
29
openspec/changes/account-settings/specs/journal-auth/spec.md
Normal file
29
openspec/changes/account-settings/specs/journal-auth/spec.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Add passkey from new device
|
||||
The Journal SHALL allow logged-in users to register additional passkeys from the account settings page or via the post-login prompt.
|
||||
|
||||
#### Scenario: Add passkey after magic link login
|
||||
- **WHEN** a user logs in via magic link on a device that supports WebAuthn
|
||||
- **THEN** the system prompts them to register a passkey for that device
|
||||
|
||||
#### Scenario: Add passkey from settings
|
||||
- **WHEN** a user clicks "Add passkey" in the security section of account settings
|
||||
- **THEN** the browser passkey creation prompt appears and the new passkey is stored
|
||||
|
||||
#### Scenario: Add passkey prompt on unsupported browser
|
||||
- **WHEN** a user logs in via magic link on a device that does not support WebAuthn
|
||||
- **THEN** the system shows the add-passkey prompt with a message that the browser does not support passkeys
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Delete passkey
|
||||
The Journal SHALL allow users to delete individual passkeys from account settings.
|
||||
|
||||
#### Scenario: Delete passkey
|
||||
- **WHEN** a user deletes a passkey from account settings
|
||||
- **THEN** the credential is removed from the database and can no longer be used for authentication
|
||||
|
||||
#### Scenario: Delete last passkey warning
|
||||
- **WHEN** a user attempts to delete their only remaining passkey
|
||||
- **THEN** a warning is shown explaining they will need to use magic links, and deletion proceeds after confirmation
|
||||
63
openspec/changes/account-settings/tasks.md
Normal file
63
openspec/changes/account-settings/tasks.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
## 1. Settings Route & Layout
|
||||
|
||||
- [ ] 1.1 Create `apps/journal/app/routes/settings.tsx` with loader auth guard (redirect to `/auth/login` if unauthenticated)
|
||||
- [ ] 1.2 Register route in `apps/journal/app/routes.ts`
|
||||
- [ ] 1.3 Layout with three sections: Profile, Security, Account (anchor links for navigation)
|
||||
|
||||
## 2. Profile Section
|
||||
|
||||
- [ ] 2.1 Display name and bio form fields, pre-filled from current user data
|
||||
- [ ] 2.2 Create `POST /api/settings/profile` action to update display name and bio
|
||||
- [ ] 2.3 Register API route in `routes.ts`
|
||||
- [ ] 2.4 Success/error feedback on save
|
||||
|
||||
## 3. Security Section — Passkey Management
|
||||
|
||||
- [ ] 3.1 Load user's credentials in settings loader (device type, transports, createdAt)
|
||||
- [ ] 3.2 Render passkey list with friendly transport labels and registration date
|
||||
- [ ] 3.3 Add passkey button using existing `addPasskeyStart`/`addPasskeyFinish` flow, hidden when WebAuthn unsupported
|
||||
- [ ] 3.4 Create `POST /api/settings/passkey/delete` action to remove a credential by ID
|
||||
- [ ] 3.5 Register API route in `routes.ts`
|
||||
- [ ] 3.6 Delete confirmation dialog, with last-passkey warning when applicable
|
||||
|
||||
## 4. Account Section — Email Change
|
||||
|
||||
- [ ] 4.1 Email display with "Change" button revealing input for new email
|
||||
- [ ] 4.2 Create `initiateEmailChange(userId, newEmail)` in auth.server.ts — generates magic token with `newEmail` metadata
|
||||
- [ ] 4.3 Create `POST /api/settings/email` action to initiate email change
|
||||
- [ ] 4.4 Register API route in `routes.ts`
|
||||
- [ ] 4.5 Update `verifyMagicToken` to handle email-change tokens (update user email)
|
||||
- [ ] 4.6 Send verification email to new address using existing email templates
|
||||
|
||||
## 5. Account Section — Deletion
|
||||
|
||||
- [ ] 5.1 "Delete account" danger button at bottom of settings page
|
||||
- [ ] 5.2 Confirmation modal requiring username input to proceed
|
||||
- [ ] 5.3 Create `POST /api/settings/delete-account` action — cascading delete, destroy session, redirect to home
|
||||
- [ ] 5.4 Register API route in `routes.ts`
|
||||
|
||||
## 6. Navigation
|
||||
|
||||
- [ ] 6.1 Add "Settings" link to Journal nav for authenticated users
|
||||
- [ ] 6.2 Move passkey count display from home page to settings security section
|
||||
|
||||
## 7. i18n
|
||||
|
||||
- [ ] 7.1 Add translation keys for all settings UI text (en + de)
|
||||
|
||||
## 8. Testing
|
||||
|
||||
### Unit tests (Vitest)
|
||||
- [ ] 8.1 Profile update: valid input saves, empty display name falls back to username
|
||||
- [ ] 8.2 Passkey delete: removes credential, rejects invalid credential ID, allows deleting last passkey
|
||||
- [ ] 8.3 Email change: creates token for new email, rejects duplicate email, rejects same-as-current email
|
||||
- [ ] 8.4 Account deletion: cascading delete removes user + credentials + tokens, destroys session
|
||||
|
||||
### E2E tests (Playwright)
|
||||
- [ ] 8.5 Auth guard: unauthenticated user visiting `/settings` is redirected to `/auth/login`
|
||||
- [ ] 8.6 Profile editing: log in, navigate to settings, update display name and bio, verify changes persist after reload, verify public profile reflects changes
|
||||
- [ ] 8.7 Passkey management: log in, navigate to security section, add passkey (virtual WebAuthn authenticator), verify it appears in list, delete it, verify removed
|
||||
- [ ] 8.8 Delete last passkey: delete only passkey, verify warning modal appears, confirm deletion, verify magic link login still works
|
||||
- [ ] 8.9 Email change: initiate email change, follow verification link (dev mode), verify email updated in settings
|
||||
- [ ] 8.10 Account deletion: click delete, type wrong username (verify blocked), type correct username, confirm, verify redirected to home and session destroyed
|
||||
- [ ] 8.11 Navigation: verify "Settings" link appears in nav when logged in, not visible when logged out
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Passkey registration
|
||||
The Journal SHALL allow new users to register using a passkey (WebAuthn). No password is required.
|
||||
The Journal SHALL allow new users to register using a passkey (WebAuthn) when the browser supports it. No password is required.
|
||||
|
||||
#### Scenario: Successful passkey registration
|
||||
- **WHEN** a user enters an email and username and creates a passkey via the browser prompt
|
||||
|
|
@ -15,8 +15,23 @@ The Journal SHALL allow new users to register using a passkey (WebAuthn). No pas
|
|||
- **WHEN** a user submits a username that is already taken
|
||||
- **THEN** the system displays an error indicating the username is not available
|
||||
|
||||
### Requirement: Magic link registration (fallback)
|
||||
The Journal SHALL allow new users to register via magic link when the browser does not support passkeys. The user can add a passkey later from a supported browser.
|
||||
|
||||
#### Scenario: Register without passkey support
|
||||
- **WHEN** a user's browser does not support WebAuthn
|
||||
- **THEN** the registration page shows a "Register with Magic Link" button instead of the passkey button
|
||||
|
||||
#### Scenario: Successful magic link registration
|
||||
- **WHEN** a user submits email and username via magic link registration
|
||||
- **THEN** a new user account is created without a credential, a verification email is sent, and the user is redirected to verify their email
|
||||
|
||||
#### Scenario: Magic link verify redirects to add-passkey
|
||||
- **WHEN** a user clicks the verification link from magic link registration
|
||||
- **THEN** they are logged in and redirected to the home page with the add-passkey prompt
|
||||
|
||||
### Requirement: Passkey login
|
||||
The Journal SHALL allow returning users to log in using a stored passkey.
|
||||
The Journal SHALL allow returning users to log in using a stored passkey when the browser supports WebAuthn.
|
||||
|
||||
#### Scenario: Successful passkey login
|
||||
- **WHEN** a user clicks "Sign in" and selects a passkey from the browser prompt
|
||||
|
|
@ -26,6 +41,10 @@ The Journal SHALL allow returning users to log in using a stored passkey.
|
|||
- **WHEN** a user has no passkey on the current device
|
||||
- **THEN** the system offers magic link login as a fallback
|
||||
|
||||
#### Scenario: Browser without WebAuthn support
|
||||
- **WHEN** a user's browser does not support WebAuthn
|
||||
- **THEN** the login page shows only the magic link option with no passkey button
|
||||
|
||||
### Requirement: Magic link login (fallback)
|
||||
The Journal SHALL allow users to log in via a magic link sent to their email. This serves as a fallback for devices without passkey support or for logging in on a new device.
|
||||
|
||||
|
|
@ -49,9 +68,20 @@ The Journal SHALL allow users to log in via a magic link sent to their email. Th
|
|||
The Journal SHALL allow logged-in users to register additional passkeys for new devices.
|
||||
|
||||
#### Scenario: Add passkey after magic link login
|
||||
- **WHEN** a user logs in via magic link on a new device
|
||||
- **WHEN** a user logs in via magic link on a device that supports WebAuthn
|
||||
- **THEN** the system prompts them to register a passkey for that device
|
||||
|
||||
#### Scenario: Add passkey prompt on unsupported browser
|
||||
- **WHEN** a user logs in via magic link on a device that does not support WebAuthn
|
||||
- **THEN** the system shows the add-passkey prompt with a message that the browser does not support passkeys
|
||||
|
||||
### Requirement: Passkey status display
|
||||
The Journal home page SHALL show the user how many passkeys they have registered.
|
||||
|
||||
#### Scenario: User with passkeys
|
||||
- **WHEN** a logged-in user visits the home page and has one or more passkeys
|
||||
- **THEN** the page displays the passkey count (e.g., "2 passkeys registered")
|
||||
|
||||
### Requirement: User profile page
|
||||
Each user SHALL have a public profile page displaying their username and routes.
|
||||
|
||||
|
|
|
|||
|
|
@ -106,6 +106,8 @@ export default {
|
|||
addPasskey: "Passkey hinzufügen",
|
||||
settingUp: "Wird eingerichtet...",
|
||||
passkeyAdded: "Passkey hinzugefügt! Du kannst dich jetzt sofort auf diesem Gerät anmelden.",
|
||||
passkeyStatus: "{{count}} Passkey registriert",
|
||||
passkeyStatus_other: "{{count}} Passkeys registriert",
|
||||
routes: {
|
||||
title: "Routen",
|
||||
myRoutes: "Meine Routen",
|
||||
|
|
@ -147,6 +149,9 @@ export default {
|
|||
registerDescription: "Registriere dich mit einem Passkey — kein Passwort nötig.",
|
||||
registerWithPasskey: "Mit Passkey registrieren",
|
||||
creatingPasskey: "Erstelle Passkey...",
|
||||
passkeyNotSupported: "Dein Browser unterstützt keine Passkeys. Bitte verwende einen anderen Browser zur Registrierung.",
|
||||
passkeyNotSupportedRegister: "Dein Browser unterstützt keine Passkeys. Du kannst dich stattdessen per Magic Link registrieren und später einen Passkey hinzufügen.",
|
||||
registerWithMagicLink: "Per Magic Link registrieren",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -106,6 +106,8 @@ export default {
|
|||
addPasskey: "Add Passkey",
|
||||
settingUp: "Setting up...",
|
||||
passkeyAdded: "Passkey added! You can now sign in instantly on this device.",
|
||||
passkeyStatus: "{{count}} passkey registered",
|
||||
passkeyStatus_other: "{{count}} passkeys registered",
|
||||
routes: {
|
||||
title: "Routes",
|
||||
myRoutes: "My Routes",
|
||||
|
|
@ -147,6 +149,9 @@ export default {
|
|||
registerDescription: "Register with a passkey — no password needed.",
|
||||
registerWithPasskey: "Register with Passkey",
|
||||
creatingPasskey: "Creating passkey...",
|
||||
passkeyNotSupported: "Your browser does not support passkeys. Please use a different browser to register.",
|
||||
passkeyNotSupportedRegister: "Your browser doesn't support passkeys. You can register with a magic link instead and add a passkey later from a supported browser.",
|
||||
registerWithMagicLink: "Register with Magic Link",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue