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 ---
|
// --- Passkey Login ---
|
||||||
|
|
||||||
export async function startAuthentication() {
|
export async function startAuthentication() {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import { data } from "react-router";
|
import { data } from "react-router";
|
||||||
import type { Route } from "./+types/api.auth.register";
|
import type { Route } from "./+types/api.auth.register";
|
||||||
import { startRegistration, finishRegistration, createSession, addPasskeyStart, addPasskeyFinish } from "~/lib/auth.server";
|
import { startRegistration, finishRegistration, createSession, addPasskeyStart, addPasskeyFinish, registerWithMagicLink } from "~/lib/auth.server";
|
||||||
import { sendWelcome } from "~/lib/email.server";
|
import { sendWelcome, sendMagicLink } from "~/lib/email.server";
|
||||||
import { logger } from "~/lib/logger.server";
|
import { logger } from "~/lib/logger.server";
|
||||||
|
|
||||||
export async function action({ request }: Route.ActionArgs) {
|
export async function action({ request }: Route.ActionArgs) {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { step, email, username, response, challenge, userId } = body;
|
const { step, email, username, response, challenge, userId } = body;
|
||||||
|
const origin = process.env.ORIGIN ?? `http://localhost:3000`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (step === "start") {
|
if (step === "start") {
|
||||||
|
|
@ -17,13 +18,25 @@ export async function action({ request }: Route.ActionArgs) {
|
||||||
if (step === "finish") {
|
if (step === "finish") {
|
||||||
const newUserId = await finishRegistration(userId, email, username, response, challenge);
|
const newUserId = await finishRegistration(userId, email, username, response, challenge);
|
||||||
const cookie = await createSession(newUserId, request);
|
const cookie = await createSession(newUserId, request);
|
||||||
// Send welcome email (fire-and-forget — don't block registration on email)
|
|
||||||
sendWelcome(email, username).catch((err) =>
|
sendWelcome(email, username).catch((err) =>
|
||||||
logger.error({ err }, "Failed to send welcome email"),
|
logger.error({ err }, "Failed to send welcome email"),
|
||||||
);
|
);
|
||||||
return data({ step: "done" }, { headers: { "Set-Cookie": cookie } });
|
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") {
|
if (step === "add-passkey") {
|
||||||
const options = await addPasskeyStart(userId);
|
const options = await addPasskeyStart(userId);
|
||||||
return data({ step: "challenge", options });
|
return data({ step: "challenge", options });
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,18 @@
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const { t } = useTranslation("journal");
|
const { t } = useTranslation("journal");
|
||||||
|
const [supportsPasskey, setSupportsPasskey] = useState(true);
|
||||||
const [mode, setMode] = useState<"passkey" | "magic-link">("passkey");
|
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 [email, setEmail] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
@ -90,7 +99,7 @@ export default function LoginPage() {
|
||||||
<div className="mx-auto max-w-md px-4 py-16">
|
<div className="mx-auto max-w-md px-4 py-16">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">{t("auth.login")}</h1>
|
<h1 className="text-2xl font-bold text-gray-900">{t("auth.login")}</h1>
|
||||||
|
|
||||||
{mode === "passkey" && (
|
{mode === "passkey" && supportsPasskey && (
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<button
|
<button
|
||||||
onClick={handlePasskeyLogin}
|
onClick={handlePasskeyLogin}
|
||||||
|
|
@ -138,15 +147,17 @@ export default function LoginPage() {
|
||||||
{loading ? t("auth.sending") : t("auth.sendMagicLink")}
|
{loading ? t("auth.sending") : t("auth.sendMagicLink")}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="text-center">
|
{supportsPasskey && (
|
||||||
<button
|
<div className="text-center">
|
||||||
type="button"
|
<button
|
||||||
onClick={() => setMode("passkey")}
|
type="button"
|
||||||
className="text-sm text-gray-500 hover:text-gray-700"
|
onClick={() => setMode("passkey")}
|
||||||
>
|
className="text-sm text-gray-500 hover:text-gray-700"
|
||||||
{t("auth.backToPasskey")}
|
>
|
||||||
</button>
|
{t("auth.backToPasskey")}
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
|
|
@ -7,14 +7,21 @@ export default function RegisterPage() {
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
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();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Step 1: Get registration options from server
|
|
||||||
const startResp = await fetch("/api/auth/register", {
|
const startResp = await fetch("/api/auth/register", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
|
@ -28,11 +35,9 @@ export default function RegisterPage() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Create passkey via browser
|
|
||||||
const { startRegistration: startWebAuthn } = await import("@simplewebauthn/browser");
|
const { startRegistration: startWebAuthn } = await import("@simplewebauthn/browser");
|
||||||
const webAuthnResp = await startWebAuthn(startData.options);
|
const webAuthnResp = await startWebAuthn(startData.options);
|
||||||
|
|
||||||
// Step 3: Send response to server
|
|
||||||
const finishResp = await fetch("/api/auth/register", {
|
const finishResp = await fetch("/api/auth/register", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
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 (
|
return (
|
||||||
<div className="mx-auto max-w-md px-4 py-16">
|
<div className="mx-auto max-w-md px-4 py-16">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">{t("auth.register")}</h1>
|
<h1 className="text-2xl font-bold text-gray-900">{t("auth.register")}</h1>
|
||||||
|
|
@ -66,7 +114,7 @@ export default function RegisterPage() {
|
||||||
{t("auth.registerDescription")}
|
{t("auth.registerDescription")}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form onSubmit={handleRegister} className="mt-8 space-y-4">
|
<form onSubmit={supportsPasskey ? handleRegisterPasskey : handleRegisterMagicLink} className="mt-8 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||||
{t("auth.email")}
|
{t("auth.email")}
|
||||||
|
|
@ -103,13 +151,29 @@ export default function RegisterPage() {
|
||||||
<p className="text-sm text-red-600">{error}</p>
|
<p className="text-sm text-red-600">{error}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
{supportsPasskey ? (
|
||||||
type="submit"
|
<button
|
||||||
disabled={loading}
|
type="submit"
|
||||||
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
|
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>
|
{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>
|
</form>
|
||||||
|
|
||||||
<p className="mt-6 text-center text-sm text-gray-500">
|
<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 { data } from "react-router";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { eq, count } from "drizzle-orm";
|
||||||
import type { Route } from "./+types/home";
|
import type { Route } from "./+types/home";
|
||||||
import { getSessionUser } from "~/lib/auth.server";
|
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) {
|
export function meta(_args: Route.MetaArgs) {
|
||||||
return [
|
return [
|
||||||
|
|
@ -15,18 +18,39 @@ export async function loader({ request }: Route.LoaderArgs) {
|
||||||
const user = await getSessionUser(request);
|
const user = await getSessionUser(request);
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const showAddPasskey = url.searchParams.get("add-passkey") === "1" && user !== null;
|
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({
|
return data({
|
||||||
user: user ? { id: user.id, username: user.username, displayName: user.displayName } : null,
|
user: user ? { id: user.id, username: user.username, displayName: user.displayName } : null,
|
||||||
showAddPasskey,
|
showAddPasskey,
|
||||||
|
passkeyCount,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Home({ loaderData }: Route.ComponentProps) {
|
export default function Home({ loaderData }: Route.ComponentProps) {
|
||||||
const { user, showAddPasskey } = loaderData;
|
const { user, showAddPasskey, passkeyCount } = loaderData;
|
||||||
const { t } = useTranslation("journal");
|
const { t } = useTranslation("journal");
|
||||||
const [addingPasskey, setAddingPasskey] = useState(false);
|
const [addingPasskey, setAddingPasskey] = useState(false);
|
||||||
const [passkeyDone, setPasskeyDone] = useState(false);
|
const [passkeyDone, setPasskeyDone] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 () => {
|
const handleAddPasskey = useCallback(async () => {
|
||||||
if (!user) return;
|
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>
|
{t("welcome")} <a href={`/users/${user.username}`} className="text-blue-600 hover:underline">{user.displayName ?? user.username}</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{showAddPasskey && !passkeyDone && (
|
{showAddPasskey && !passkeyDone && supportsPasskey === true && (
|
||||||
<div className="mt-6 rounded-md bg-blue-50 p-4">
|
<div className="mt-6 rounded-md bg-blue-50 p-4">
|
||||||
<p className="text-sm text-blue-800">
|
<p className="text-sm text-blue-800">
|
||||||
{t("addPasskeyPrompt")}
|
{t("addPasskeyPrompt")}
|
||||||
|
|
@ -101,6 +125,17 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
||||||
</div>
|
</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 && (
|
{passkeyDone && (
|
||||||
<div className="mt-6 rounded-md bg-green-50 p-4">
|
<div className="mt-6 rounded-md bg-green-50 p-4">
|
||||||
<p className="text-sm text-green-800">
|
<p className="text-sm text-green-800">
|
||||||
|
|
@ -108,6 +143,12 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{user && passkeyCount > 0 && !showAddPasskey && (
|
||||||
|
<p className="mt-4 text-sm text-gray-500">
|
||||||
|
{t("passkeyStatus", { count: passkeyCount })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-8 flex gap-4">
|
<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
|
## ADDED Requirements
|
||||||
|
|
||||||
### Requirement: Passkey registration
|
### 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
|
#### Scenario: Successful passkey registration
|
||||||
- **WHEN** a user enters an email and username and creates a passkey via the browser prompt
|
- **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
|
- **WHEN** a user submits a username that is already taken
|
||||||
- **THEN** the system displays an error indicating the username is not available
|
- **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
|
### 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
|
#### Scenario: Successful passkey login
|
||||||
- **WHEN** a user clicks "Sign in" and selects a passkey from the browser prompt
|
- **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
|
- **WHEN** a user has no passkey on the current device
|
||||||
- **THEN** the system offers magic link login as a fallback
|
- **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)
|
### 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.
|
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.
|
The Journal SHALL allow logged-in users to register additional passkeys for new devices.
|
||||||
|
|
||||||
#### Scenario: Add passkey after magic link login
|
#### 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
|
- **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
|
### Requirement: User profile page
|
||||||
Each user SHALL have a public profile page displaying their username and routes.
|
Each user SHALL have a public profile page displaying their username and routes.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,8 @@ export default {
|
||||||
addPasskey: "Passkey hinzufügen",
|
addPasskey: "Passkey hinzufügen",
|
||||||
settingUp: "Wird eingerichtet...",
|
settingUp: "Wird eingerichtet...",
|
||||||
passkeyAdded: "Passkey hinzugefügt! Du kannst dich jetzt sofort auf diesem Gerät anmelden.",
|
passkeyAdded: "Passkey hinzugefügt! Du kannst dich jetzt sofort auf diesem Gerät anmelden.",
|
||||||
|
passkeyStatus: "{{count}} Passkey registriert",
|
||||||
|
passkeyStatus_other: "{{count}} Passkeys registriert",
|
||||||
routes: {
|
routes: {
|
||||||
title: "Routen",
|
title: "Routen",
|
||||||
myRoutes: "Meine Routen",
|
myRoutes: "Meine Routen",
|
||||||
|
|
@ -147,6 +149,9 @@ export default {
|
||||||
registerDescription: "Registriere dich mit einem Passkey — kein Passwort nötig.",
|
registerDescription: "Registriere dich mit einem Passkey — kein Passwort nötig.",
|
||||||
registerWithPasskey: "Mit Passkey registrieren",
|
registerWithPasskey: "Mit Passkey registrieren",
|
||||||
creatingPasskey: "Erstelle Passkey...",
|
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;
|
} as const;
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,8 @@ export default {
|
||||||
addPasskey: "Add Passkey",
|
addPasskey: "Add Passkey",
|
||||||
settingUp: "Setting up...",
|
settingUp: "Setting up...",
|
||||||
passkeyAdded: "Passkey added! You can now sign in instantly on this device.",
|
passkeyAdded: "Passkey added! You can now sign in instantly on this device.",
|
||||||
|
passkeyStatus: "{{count}} passkey registered",
|
||||||
|
passkeyStatus_other: "{{count}} passkeys registered",
|
||||||
routes: {
|
routes: {
|
||||||
title: "Routes",
|
title: "Routes",
|
||||||
myRoutes: "My Routes",
|
myRoutes: "My Routes",
|
||||||
|
|
@ -147,6 +149,9 @@ export default {
|
||||||
registerDescription: "Register with a passkey — no password needed.",
|
registerDescription: "Register with a passkey — no password needed.",
|
||||||
registerWithPasskey: "Register with Passkey",
|
registerWithPasskey: "Register with Passkey",
|
||||||
creatingPasskey: "Creating 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;
|
} as const;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue