feat(security): require first-install Admin setup and authenticated login with Argon2id + HttpOnly SHA-256 sessions

This commit is contained in:
Schwitzz
2026-10-30 16:05:44 -05:00
parent 65d06e1681
commit 09e9280da6
8 changed files with 1092 additions and 170 deletions
+28 -25
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from "react";
import { Routes, Route } from "react-router-dom";
import { Sidebar, Topbar, CommandPalette, SplashScreen } from "./components/index.js";
import { AuthGate } from "./components/AuthGate.js";
import { ExportDialog } from "./features/export/ExportDialog.js";
import { AISidebar } from "./features/ai/AISidebar.js";
import { ToastViewport } from "@pubscript/ui";
@@ -45,31 +46,33 @@ export default function App() {
}, []);
return (
<div className={`ps-app${navCollapsed ? " is-nav-collapsed" : ""}${aiSidebarOpen ? " is-ai-open" : ""}${focusMode ? " is-focus-mode" : ""}`}>
<SplashScreen />
<Sidebar nav={NAV} />
<Topbar onOpenPalette={() => setPaletteOpen(true)} onOpenExport={() => setExportOpen(true)} />
<div className="ps-workspace-row">
<main className="ps-main">
<Routes>
<Route path="/" element={<Write />} />
<Route path="/characters" element={<Characters />} />
<Route path="/plot" element={<Plot />} />
<Route path="/timeline" element={<Timeline />} />
<Route path="/whiteboard" element={<Whiteboard />} />
<Route path="/ai" element={<AI />} />
<Route path="/projects" element={<Projects />} />
<Route path="/settings" element={<Settings />} />
<Route path="/admin" element={<Admin />} />
<Route path="*" element={<Write />} />
</Routes>
</main>
{aiSidebarOpen && <AISidebar />}
</div>
<AuthGate>
<div className={`ps-app${navCollapsed ? " is-nav-collapsed" : ""}${aiSidebarOpen ? " is-ai-open" : ""}${focusMode ? " is-focus-mode" : ""}`}>
<SplashScreen />
<Sidebar nav={NAV} />
<Topbar onOpenPalette={() => setPaletteOpen(true)} onOpenExport={() => setExportOpen(true)} />
<div className="ps-workspace-row">
<main className="ps-main">
<Routes>
<Route path="/" element={<Write />} />
<Route path="/characters" element={<Characters />} />
<Route path="/plot" element={<Plot />} />
<Route path="/timeline" element={<Timeline />} />
<Route path="/whiteboard" element={<Whiteboard />} />
<Route path="/ai" element={<AI />} />
<Route path="/projects" element={<Projects />} />
<Route path="/settings" element={<Settings />} />
<Route path="/admin" element={<Admin />} />
<Route path="*" element={<Write />} />
</Routes>
</main>
{aiSidebarOpen && <AISidebar />}
</div>
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} nav={NAV} />
<ExportDialog open={exportOpen} onClose={() => setExportOpen(false)} />
<ToastViewport />
</div>
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} nav={NAV} />
<ExportDialog open={exportOpen} onClose={() => setExportOpen(false)} />
<ToastViewport />
</div>
</AuthGate>
);
}
+327 -100
View File
@@ -2,8 +2,10 @@ import { createUlid, type AuditLogEntry, type Role, type User } from "@pubscript
import { useSettings } from "./store/settings.js";
const BASE = "/api";
const TOKEN_KEY = "pubscript.admin.token";
const LOCAL_USERS_KEY = "pubscript.local.admin.users.v1";
const TOKEN_KEY = "pubscript.auth.token.v1";
const LOCAL_AUTH_KEY = "pubscript.local.auth.v2";
const LOCAL_SESSION_KEY = "pubscript.local.session.v2";
const LOCAL_USERS_KEY = "pubscript.local.admin.users.v2";
const LOCAL_AUDIT_KEY = "pubscript.local.admin.audit.v1";
export interface AdminStats {
@@ -15,7 +17,19 @@ export interface AdminStats {
disabledUsers: number;
}
function getStoredToken(): string | null {
export interface AuthStatusResponse {
initialized: boolean;
authenticated: boolean;
user: User | null;
}
interface LocalAuthRecord {
user: User;
saltHex: string;
hashHex: string;
}
export function getStoredToken(): string | null {
try {
return localStorage.getItem(TOKEN_KEY);
} catch {
@@ -23,7 +37,7 @@ function getStoredToken(): string | null {
}
}
function setStoredToken(token: string | null): void {
export function setStoredToken(token: string | null): void {
try {
if (token) localStorage.setItem(TOKEN_KEY, token);
else localStorage.removeItem(TOKEN_KEY);
@@ -32,7 +46,101 @@ function setStoredToken(token: string | null): void {
}
}
function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
function hexToBytes(hex: string): Uint8Array {
const clean = hex.length % 2 === 0 ? hex : "0" + hex;
const out = new Uint8Array(clean.length / 2);
for (let i = 0; i < out.length; i++) {
out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
}
return out;
}
async function derivePbkdf2Hex(password: string, saltHex: string): Promise<string> {
if (typeof crypto !== "undefined" && crypto.subtle) {
const enc = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey(
"raw",
enc.encode(password),
{ name: "PBKDF2" },
false,
["deriveBits"],
);
const saltBytes = hexToBytes(saltHex);
const bits = await crypto.subtle.deriveBits(
{
name: "PBKDF2",
salt: saltBytes.buffer as ArrayBuffer,
iterations: 150_000,
hash: "SHA-256",
},
keyMaterial,
256,
);
return bytesToHex(new Uint8Array(bits));
}
// Fallback only if SubtleCrypto is unavailable in test env
return btoa(`${saltHex}:${password}`);
}
function loadLocalAuthRecords(): LocalAuthRecord[] {
try {
const raw = localStorage.getItem(LOCAL_AUTH_KEY);
if (raw) {
const parsed = JSON.parse(raw) as LocalAuthRecord[];
if (Array.isArray(parsed)) return parsed;
}
} catch {
/* ignore */
}
return [];
}
function saveLocalAuthRecords(records: LocalAuthRecord[]): void {
try {
localStorage.setItem(LOCAL_AUTH_KEY, JSON.stringify(records));
localStorage.setItem(
LOCAL_USERS_KEY,
JSON.stringify(records.map((r) => r.user)),
);
} catch {
/* ignore */
}
}
function getLocalSessionUser(): User | null {
try {
const userId = sessionStorage.getItem(LOCAL_SESSION_KEY) ?? localStorage.getItem(LOCAL_SESSION_KEY);
if (!userId) return null;
const rec = loadLocalAuthRecords().find((r) => r.user.id === userId && !r.user.disabled);
return rec?.user ?? null;
} catch {
return null;
}
}
function setLocalSessionUser(user: User | null): void {
try {
if (user) {
sessionStorage.setItem(LOCAL_SESSION_KEY, user.id);
localStorage.setItem(LOCAL_SESSION_KEY, user.id);
} else {
sessionStorage.removeItem(LOCAL_SESSION_KEY);
localStorage.removeItem(LOCAL_SESSION_KEY);
}
} catch {
/* ignore */
}
}
function loadLocalUsers(): User[] {
const fromAuth = loadLocalAuthRecords().map((r) => r.user);
if (fromAuth.length > 0) return fromAuth;
try {
const raw = localStorage.getItem(LOCAL_USERS_KEY);
if (raw) {
@@ -42,30 +150,7 @@ function loadLocalUsers(): User[] {
} catch {
/* ignore */
}
const initial: User[] = [
{
id: createUlid(),
email: "admin@pubscript.local",
displayName: "Local Admin",
role: "admin",
disabled: false,
createdAt: new Date().toISOString(),
},
{
id: createUlid(),
email: "mara@pubscript.local",
displayName: "Mara Vell",
role: "author",
disabled: false,
createdAt: new Date().toISOString(),
},
];
try {
localStorage.setItem(LOCAL_USERS_KEY, JSON.stringify(initial));
} catch {
/* ignore */
}
return initial;
return [];
}
function saveLocalUsers(users: User[]): void {
@@ -86,29 +171,15 @@ function loadLocalAudit(): AuditLogEntry[] {
} catch {
/* ignore */
}
const initial: AuditLogEntry[] = [
{
id: createUlid(),
at: new Date().toISOString(),
userId: "admin@pubscript.local",
action: "admin.bootstrap",
detail: { email: "admin@pubscript.local" },
},
];
try {
localStorage.setItem(LOCAL_AUDIT_KEY, JSON.stringify(initial));
} catch {
/* ignore */
}
return initial;
return [];
}
function appendLocalAudit(action: string, detail: Record<string, unknown>): void {
function appendLocalAudit(action: string, detail: Record<string, unknown>, userId = "admin"): void {
const entries = loadLocalAudit();
entries.unshift({
id: createUlid(),
at: new Date().toISOString(),
userId: "admin@pubscript.local",
userId,
action,
detail,
});
@@ -119,35 +190,22 @@ function appendLocalAudit(action: string, detail: Record<string, unknown>): void
}
}
async function bootstrapAdminSession(): Promise<string | null> {
try {
const res = await fetch(`${BASE}/auth/bootstrap-admin`, {
method: "POST",
credentials: "include",
});
const ct = res.headers.get("content-type") ?? "";
if (!res.ok || !ct.includes("application/json")) return null;
const data = (await res.json()) as { token?: string };
if (data.token) {
setStoredToken(data.token);
return data.token;
}
} catch {
/* server offline */
}
return null;
function authHeaders(extra?: Record<string, string>): Record<string, string> {
const token = getStoredToken();
return {
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(extra ?? {}),
};
}
async function request<T>(path: string, init?: RequestInit, retried = false): Promise<T> {
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const { serverEnabled } = useSettings.getState();
if (!serverEnabled) {
throw new Error("Server disabled in settings (Local Storage Mode)");
}
const token = getStoredToken();
const headers: Record<string, string> = {
...(init?.body ? { "Content-Type": "application/json" } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...((init?.headers as Record<string, string> | undefined) ?? {}),
...authHeaders(init?.headers as Record<string, string> | undefined),
};
const res = await fetch(`${BASE}${path}`, {
@@ -158,19 +216,13 @@ async function request<T>(path: string, init?: RequestInit, retried = false): Pr
const contentType = res.headers.get("content-type") ?? "";
if (res.status === 401 && !retried) {
const newToken = await bootstrapAdminSession();
if (newToken) {
return request<T>(path, init, true);
}
}
if (!res.ok) {
let detail = res.statusText;
if (contentType.includes("application/json")) {
try {
const body = (await res.json()) as { error?: string };
if (body.error) detail = body.error;
const body = (await res.json()) as { error?: string; message?: string };
if (body.message) detail = body.message;
else if (body.error) detail = body.error;
} catch {
/* ignore */
}
@@ -188,7 +240,189 @@ async function request<T>(path: string, init?: RequestInit, retried = false): Pr
}
export const api = {
// Admin
// ---- Auth & First-Install Setup ----
authStatus: async (): Promise<AuthStatusResponse> => {
try {
const res = await fetch(`${BASE}/auth/status`, {
credentials: "include",
headers: authHeaders(),
});
const ct = res.headers.get("content-type") ?? "";
if (res.ok && ct.includes("application/json")) {
const data = (await res.json()) as AuthStatusResponse;
return data;
}
} catch {
/* offline fallback */
}
const records = loadLocalAuthRecords();
const currentUser = getLocalSessionUser();
return {
initialized: records.length > 0,
authenticated: Boolean(currentUser),
user: currentUser,
};
},
setupAdmin: async (input: {
email: string;
displayName: string;
password: string;
}): Promise<{ token: string; user: User }> => {
const cleanEmail = input.email.trim().toLowerCase();
const cleanName = input.displayName.trim();
const salt = new Uint8Array(16);
if (typeof crypto !== "undefined" && crypto.getRandomValues) {
crypto.getRandomValues(salt);
}
const saltHex = bytesToHex(salt);
const hashHex = await derivePbkdf2Hex(input.password, saltHex);
try {
const res = await fetch(`${BASE}/auth/setup`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: cleanEmail,
displayName: cleanName,
password: input.password,
}),
});
const ct = res.headers.get("content-type") ?? "";
if (ct.includes("application/json")) {
const data = (await res.json()) as {
token?: string;
user?: User;
error?: string;
message?: string;
};
if (!res.ok) {
throw new Error(data.message ?? data.error ?? "Failed to set up administrator account");
}
if (data.token && data.user) {
setStoredToken(data.token);
saveLocalAuthRecords([{ user: data.user, saltHex, hashHex }]);
setLocalSessionUser(data.user);
return { token: data.token, user: data.user };
}
}
} catch (e) {
if (e instanceof Error && (e.message.includes("already_initialized") || e.message.includes("already been"))) {
throw e;
}
}
// Offline local setup fallback
const existing = loadLocalAuthRecords();
if (existing.length > 0) {
throw new Error("Administrator account has already been created. Please sign in.");
}
const localUser: User = {
id: createUlid(),
email: cleanEmail,
displayName: cleanName,
role: "admin",
disabled: false,
createdAt: new Date().toISOString(),
};
const token = createUlid();
setStoredToken(token);
saveLocalAuthRecords([{ user: localUser, saltHex, hashHex }]);
setLocalSessionUser(localUser);
appendLocalAudit("auth.setup", { email: cleanEmail }, localUser.id);
return { token, user: localUser };
},
login: async (email: string, password: string): Promise<{ token: string; user: User }> => {
const cleanEmail = email.trim().toLowerCase();
try {
const res = await fetch(`${BASE}/auth/login`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: cleanEmail, password }),
});
const ct = res.headers.get("content-type") ?? "";
if (ct.includes("application/json")) {
const data = (await res.json()) as {
token?: string;
user?: User;
error?: string;
};
if (res.status === 429) {
throw new Error("Too many sign-in attempts. Please wait a minute and try again.");
}
if (!res.ok || !data.token || !data.user) {
throw new Error("Invalid email or password.");
}
setStoredToken(data.token);
setLocalSessionUser(data.user);
return { token: data.token, user: data.user };
}
} catch (e) {
if (
e instanceof Error &&
(e.message.includes("Invalid email") || e.message.includes("Too many sign-in"))
) {
throw e;
}
}
// Offline local verification fallback
const records = loadLocalAuthRecords();
const record = records.find((r) => r.user.email.toLowerCase() === cleanEmail);
if (!record || record.user.disabled) {
throw new Error("Invalid email or password.");
}
const candidateHash = await derivePbkdf2Hex(password, record.saltHex);
if (candidateHash !== record.hashHex) {
appendLocalAudit("auth.login_failed", { email: cleanEmail }, record.user.id);
throw new Error("Invalid email or password.");
}
const token = createUlid();
setStoredToken(token);
setLocalSessionUser(record.user);
appendLocalAudit("auth.login", { email: cleanEmail }, record.user.id);
return { token, user: record.user };
},
logout: async (): Promise<{ ok: true }> => {
try {
await fetch(`${BASE}/auth/logout`, {
method: "POST",
credentials: "include",
headers: authHeaders(),
});
} catch {
/* ignore */
}
setStoredToken(null);
setLocalSessionUser(null);
return { ok: true };
},
changePassword: async (
currentPassword: string,
newPassword: string,
): Promise<{ ok: true }> => {
const out = await request<{ ok: true; token?: string }>("/auth/change-password", {
method: "POST",
body: JSON.stringify({ currentPassword, newPassword }),
});
if (out.token) setStoredToken(out.token);
return { ok: true };
},
me: () => request<{ user: User | null }>("/auth/me"),
register: (email: string, password: string, displayName: string) =>
request<{ token: string; user: User }>("/auth/register", {
method: "POST",
body: JSON.stringify({ email, password, displayName }),
}),
// ---- Admin ----
adminStats: async (): Promise<AdminStats> => {
try {
return await request<AdminStats>("/admin/stats");
@@ -237,15 +471,19 @@ export const api = {
}
const created: User = {
id: createUlid(),
email: input.email,
displayName: input.displayName,
email: input.email.trim().toLowerCase(),
displayName: input.displayName.trim(),
role: input.role,
disabled: false,
createdAt: new Date().toISOString(),
};
users.unshift(created);
saveLocalUsers(users);
appendLocalAudit("user_create", { targetId: created.id, email: created.email, role: created.role });
appendLocalAudit("user_create", {
targetId: created.id,
email: created.email,
role: created.role,
});
return created;
}
},
@@ -350,23 +588,6 @@ export const api = {
}
},
// Auth
me: () => request<{ user: User | null }>("/auth/me"),
login: (email: string, password: string) =>
request<{ token: string; user: User }>("/auth/login", {
method: "POST",
body: JSON.stringify({ email, password }),
}),
register: (email: string, password: string, displayName: string) =>
request<{ token: string; user: User }>("/auth/register", {
method: "POST",
body: JSON.stringify({ email, password, displayName }),
}),
logout: () => request<{ ok: true }>("/auth/logout", { method: "POST" }),
getServerConfig: async (portHint?: number): Promise<{
online: boolean;
enabled: boolean;
@@ -386,7 +607,10 @@ export const api = {
];
for (const url of endpoints) {
try {
const res = await fetch(url, { credentials: "include" });
const res = await fetch(url, {
credentials: "include",
headers: authHeaders(),
});
const ct = res.headers.get("content-type") ?? "";
if (res.ok && ct.includes("application/json")) {
const data = (await res.json()) as {
@@ -463,7 +687,7 @@ export const api = {
const res = await fetch(url, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify(patch),
});
const ct = res.headers.get("content-type") ?? "";
@@ -527,7 +751,10 @@ export const api = {
];
for (const url of endpoints) {
try {
const res = await fetch(url, { credentials: "include" });
const res = await fetch(url, {
credentials: "include",
headers: authHeaders(),
});
const ct = res.headers.get("content-type") ?? "";
if (res.ok && ct.includes("application/json")) {
const data = (await res.json()) as {
@@ -585,7 +812,7 @@ export const api = {
const res = await fetch(url, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({
directory: params.directory,
fileName: params.fileName,
+404
View File
@@ -0,0 +1,404 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import type { User } from "@pubscript/core";
import { Badge, Button, Input, toast } from "@pubscript/ui";
import { api } from "../api.js";
interface AuthContextValue {
user: User | null;
logout: () => Promise<void>;
refreshAuth: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue>({
user: null,
logout: async () => {},
refreshAuth: async () => {},
});
export function useAuth(): AuthContextValue {
return useContext(AuthContext);
}
function scorePassword(pw: string): { score: number; label: string; color: string } {
if (!pw) return { score: 0, label: "Enter a password", color: "var(--ps-text-muted)" };
let score = 0;
if (pw.length >= 8) score += 1;
if (pw.length >= 12) score += 1;
if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) score += 1;
if (/\d/.test(pw)) score += 1;
if (/[^a-zA-Z0-9]/.test(pw)) score += 1;
if (pw.length < 8) {
return { score: 1, label: "Too short (minimum 8 characters)", color: "var(--ps-danger)" };
}
if (score <= 2) {
return { score: 2, label: "Fair", color: "var(--ps-warning)" };
}
if (score === 3) {
return { score: 3, label: "Good", color: "var(--ps-accent)" };
}
return { score: 4, label: "Strong (Argon2id ready)", color: "var(--ps-success)" };
}
export function AuthGate({ children }: { children: React.ReactNode }) {
const [loading, setLoading] = useState(true);
const [initialized, setInitialized] = useState(true);
const [authenticated, setAuthenticated] = useState(false);
const [user, setUser] = useState<User | null>(null);
// Setup / Login form state
const [displayName, setDisplayName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const refreshAuth = useCallback(async () => {
try {
const status = await api.authStatus();
setInitialized(status.initialized);
setAuthenticated(status.authenticated);
setUser(status.user);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refreshAuth();
}, [refreshAuth]);
const logout = useCallback(async () => {
await api.logout();
setAuthenticated(false);
setUser(null);
setPassword("");
toast.info("Signed out of PubScript");
}, []);
const strength = useMemo(() => scorePassword(password), [password]);
const handleSetupSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
const cleanName = displayName.trim();
const cleanEmail = email.trim().toLowerCase();
if (!cleanName) {
setError("Please enter an administrator display name.");
return;
}
if (!cleanEmail || !cleanEmail.includes("@")) {
setError("Please enter a valid administrator email address.");
return;
}
if (password.length < 8) {
setError("Password must be at least 8 characters long.");
return;
}
if (password !== confirmPassword) {
setError("Passwords do not match.");
return;
}
setSubmitting(true);
try {
const res = await api.setupAdmin({
email: cleanEmail,
displayName: cleanName,
password,
});
setInitialized(true);
setAuthenticated(true);
setUser(res.user);
setPassword("");
setConfirmPassword("");
toast.success(`Welcome, ${res.user.displayName}! Administrator account created.`);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create administrator account.");
} finally {
setSubmitting(false);
}
};
const handleLoginSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
const cleanEmail = email.trim().toLowerCase();
if (!cleanEmail || !password) {
setError("Please enter your email and password.");
return;
}
setSubmitting(true);
try {
const res = await api.login(cleanEmail, password);
setAuthenticated(true);
setUser(res.user);
setPassword("");
toast.success(`Signed in as ${res.user.displayName}`);
} catch (err) {
setError(err instanceof Error ? err.message : "Invalid email or password.");
} finally {
setSubmitting(false);
}
};
if (loading) {
return (
<div
style={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "var(--ps-bg)",
color: "var(--ps-text-muted)",
fontFamily: "var(--ps-font-ui)",
}}
>
Verifying session security…
</div>
);
}
if (!initialized || !authenticated) {
const isFirstInstall = !initialized;
return (
<div
style={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 24,
background:
"radial-gradient(circle at 50% 0%, color-mix(in srgb, var(--ps-accent) 14%, var(--ps-bg)), var(--ps-bg) 70%)",
color: "var(--ps-text)",
fontFamily: "var(--ps-font-ui)",
}}
>
<div
style={{
width: "min(440px, 100%)",
background: "var(--ps-surface)",
border: "1px solid var(--ps-border)",
borderRadius: 14,
padding: "28px 28px 24px",
boxShadow: "0 24px 64px rgba(0, 0, 0, 0.45)",
}}
>
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", textAlign: "center", marginBottom: 20 }}>
<img
src="/logo.svg"
alt="PubScript"
style={{ width: 72, height: 72, objectFit: "contain", marginBottom: 10 }}
/>
<div style={{ display: "flex", gap: 6, alignItems: "center", marginBottom: 8 }}>
<Badge variant={isFirstInstall ? "accent" : "success"}>
{isFirstInstall ? "🔒 First-Install Security Setup" : "🔒 Authenticated Access Required"}
</Badge>
</div>
<h1 style={{ margin: "0 0 6px", fontSize: 20, fontWeight: 700 }}>
{isFirstInstall ? "Create Administrator Account" : "Sign in to PubScript"}
</h1>
<p style={{ margin: 0, fontSize: 13, color: "var(--ps-text-muted)", lineHeight: 1.45 }}>
{isFirstInstall
? "Welcome to PubScript. Create your initial Administrator account to secure your local studio and self-hosted web access."
: "Enter your credentials to unlock your manuscripts and studio workspace."}
</p>
</div>
{error && (
<div
role="alert"
style={{
padding: "10px 12px",
marginBottom: 14,
borderRadius: 8,
border: "1px solid var(--ps-danger)",
background: "color-mix(in srgb, var(--ps-danger) 14%, transparent)",
color: "var(--ps-danger)",
fontSize: 13,
fontWeight: 500,
}}
>
{error}
</div>
)}
{isFirstInstall ? (
<form onSubmit={(e) => void handleSetupSubmit(e)} style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<Input
label="Administrator Display Name"
placeholder="e.g. Schwitzz"
autoComplete="name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
required
/>
<Input
label="Administrator Email"
type="email"
placeholder="you@example.com"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<div className="ps-field">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<label className="ps-field-label" htmlFor="ps-setup-pw">
Master Password (min 8 chars)
</label>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
style={{
border: "none",
background: "transparent",
color: "var(--ps-accent)",
fontSize: 11.5,
cursor: "pointer",
padding: 0,
}}
>
{showPassword ? "Hide" : "Show"}
</button>
</div>
<input
id="ps-setup-pw"
type={showPassword ? "text" : "password"}
className="ps-input"
placeholder="Create a strong password…"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginTop: 4 }}>
<div style={{ display: "flex", gap: 4, flex: 1, marginRight: 10 }}>
{[1, 2, 3, 4].map((step) => (
<div
key={step}
style={{
flex: 1,
height: 4,
borderRadius: 2,
background:
strength.score >= step ? strength.color : "var(--ps-border)",
transition: "background 0.15s",
}}
/>
))}
</div>
<span style={{ fontSize: 11.5, color: strength.color, fontWeight: 600 }}>
{strength.label}
</span>
</div>
</div>
<Input
label="Confirm Master Password"
type={showPassword ? "text" : "password"}
placeholder="Re-enter password…"
autoComplete="new-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
/>
<div style={{ marginTop: 8 }}>
<Button
type="submit"
variant="primary"
disabled={submitting}
style={{ width: "100%", padding: "10px 16px", fontWeight: 600 }}
>
{submitting ? "Encrypting & Creating Admin…" : "Create Administrator Account"}
</Button>
</div>
</form>
) : (
<form onSubmit={(e) => void handleLoginSubmit(e)} style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<Input
label="Email Address"
type="email"
placeholder="you@example.com"
autoComplete="username"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<div className="ps-field">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<label className="ps-field-label" htmlFor="ps-login-pw">
Password
</label>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
style={{
border: "none",
background: "transparent",
color: "var(--ps-accent)",
fontSize: 11.5,
cursor: "pointer",
padding: 0,
}}
>
{showPassword ? "Hide" : "Show"}
</button>
</div>
<input
id="ps-login-pw"
type={showPassword ? "text" : "password"}
className="ps-input"
placeholder="Enter your password…"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<div style={{ marginTop: 8 }}>
<Button
type="submit"
variant="primary"
disabled={submitting}
style={{ width: "100%", padding: "10px 16px", fontWeight: 600 }}
>
{submitting ? "Signing in…" : "Sign In"}
</Button>
</div>
</form>
)}
<div
style={{
marginTop: 18,
paddingTop: 12,
borderTop: "1px solid var(--ps-border)",
display: "flex",
justifyContent: "space-between",
fontSize: 11,
color: "var(--ps-text-muted)",
}}
>
<span>Argon2id (64 MiB) · SHA-256 Session</span>
<span>HttpOnly · SameSite=Lax</span>
</div>
</div>
</div>
);
}
return (
<AuthContext.Provider value={{ user, logout, refreshAuth }}>
{children}
</AuthContext.Provider>
);
}
+19
View File
@@ -2,6 +2,7 @@ import { useMemo, useRef, useState, useEffect } from "react";
import { useLocation, NavLink, useNavigate } from "react-router-dom";
import { useSettings } from "../store/settings.js";
import { useProjectStore } from "../store/project-store.js";
import { useAuth } from "./AuthGate.js";
import { Badge } from "@pubscript/ui";
import type { Entity } from "@pubscript/core";
@@ -30,6 +31,7 @@ function entityName(e: Entity): string {
export function Topbar({ onOpenExport }: TopbarProps) {
const location = useLocation();
const navigate = useNavigate();
const { user, logout } = useAuth();
const autoSave = useSettings((s) => s.autoSave);
const aiSidebarOpen = useSettings((s) => s.aiSidebarOpen);
const setSettings = useSettings((s) => s.set);
@@ -186,6 +188,23 @@ export function Topbar({ onOpenExport }: TopbarProps) {
<NavLink to="/settings" className="ps-icon-btn" style={{ fontSize: 18 }} aria-label="Settings" title="Settings">
⚙
</NavLink>
{user && (
<div style={{ display: "inline-flex", alignItems: "center", gap: 6, marginLeft: 4 }}>
<Badge variant="neutral" title={`${user.email} (${user.role})`}>
{`👤 ${user.displayName}`}
</Badge>
<button
type="button"
className="ps-nav-item"
onClick={() => void logout()}
style={{ width: "auto", padding: "5px 9px", fontSize: 12.5 }}
title="Sign out of PubScript"
>
Sign out
</button>
</div>
)}
</header>
);
}