diff --git a/migrations/1730000023000_users.cjs b/migrations/1730000023000_users.cjs new file mode 100644 index 0000000..dff0887 --- /dev/null +++ b/migrations/1730000023000_users.cjs @@ -0,0 +1,27 @@ +/* eslint-disable */ +/** + * 多用户与角色:用户账号表。 + * - role: 商务/销售 | 风控 | 管理层 | 系统管理员 + * - password_hash: scrypt 派生(格式 salt:hash),不存明文 + * - 默认账号由应用启动时 ensureSeedUsers 兜底创建(首次为空时) + */ + +exports.up = (pgm) => { + pgm.sql(` + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + display_name TEXT, + password_hash TEXT NOT NULL, + role TEXT NOT NULL, + active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + CREATE INDEX IF NOT EXISTS idx_users_role ON users(role); + `); +}; + +exports.down = (pgm) => { + pgm.sql(`DROP TABLE IF EXISTS users;`); +}; diff --git a/src/auth/index.ts b/src/auth/index.ts index de6c2c0..74a96a0 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -8,7 +8,7 @@ import type { Context, Next } from 'hono'; import { createHmac } from 'node:crypto'; -export type AuthRole = '商务/销售' | '风控' | '管理层'; +export type AuthRole = '商务/销售' | '风控' | '管理层' | '系统管理员'; export interface AuthPayload { username: string; diff --git a/src/persistence/index.ts b/src/persistence/index.ts index 46e2bd0..ff9604a 100644 --- a/src/persistence/index.ts +++ b/src/persistence/index.ts @@ -19,6 +19,7 @@ export * from './redlineRules.js'; export * from './customers.js'; export * from './minWages.js'; export * from './drafts.js'; +export * from './users.js'; export * from './settings.js'; export * from './regionRates.js'; export * from './rejectReasons.js'; diff --git a/src/persistence/users.ts b/src/persistence/users.ts new file mode 100644 index 0000000..6374e99 --- /dev/null +++ b/src/persistence/users.ts @@ -0,0 +1,148 @@ +/** + * 用户账号持久化(多用户 + 角色)。 + * + * 密码用 Node scrypt 派生(salt:hash),不依赖外部库;校验用 timingSafeEqual 防时序攻击。 + */ +import type pg from 'pg'; +import { scryptSync, randomBytes, timingSafeEqual } from 'node:crypto'; + +export type UserRole = '商务/销售' | '风控' | '管理层' | '系统管理员'; + +export const USER_ROLES: readonly UserRole[] = ['商务/销售', '风控', '管理层', '系统管理员']; + +/** 对外用户记录(不含密码哈希)。 */ +export interface UserRecord { + id: string; + username: string; + displayName: string | null; + role: UserRole; + active: boolean; + createdAt: string; +} + +/** scrypt 派生口令哈希,返回 `salt:hash`。 */ +export function hashPassword(password: string): string { + const salt = randomBytes(16).toString('hex'); + const hash = scryptSync(password, salt, 64).toString('hex'); + return `${salt}:${hash}`; +} + +/** 校验明文口令与存储哈希是否匹配。 */ +export function verifyPassword(password: string, stored: string): boolean { + const [salt, hash] = stored.split(':'); + if (salt === undefined || hash === undefined || salt === '' || hash === '') return false; + const derived = scryptSync(password, salt, 64); + const expected = Buffer.from(hash, 'hex'); + return derived.length === expected.length && timingSafeEqual(derived, expected); +} + +function mapRow(r: Record): UserRecord { + return { + id: String(r.id), + username: String(r.username), + displayName: r.display_name != null ? String(r.display_name) : null, + role: String(r.role) as UserRole, + active: r.active === true, + createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : String(r.created_at), + }; +} + +/** 列出全部用户(不含密码)。 */ +export async function listUsers(pool: pg.Pool): Promise { + const res = await pool.query( + 'SELECT id, username, display_name, role, active, created_at FROM users ORDER BY role, username', + ); + return (res.rows as Array>).map(mapRow); +} + +/** 按用户名取用户(含密码哈希,仅供登录校验内部使用)。 */ +export async function getUserByUsername( + pool: pg.Pool, + username: string, +): Promise<(UserRecord & { passwordHash: string }) | null> { + const res = await pool.query( + 'SELECT id, username, display_name, role, active, created_at, password_hash FROM users WHERE username = $1', + [username], + ); + const r = (res.rows as Array>)[0]; + if (!r) return null; + return { ...mapRow(r), passwordHash: String(r.password_hash) }; +} + +export interface CreateUserInput { + username: string; + displayName?: string | null; + password: string; + role: UserRole; +} + +/** 新增用户。用户名已存在则抛错。 */ +export async function createUser(pool: pg.Pool, input: CreateUserInput): Promise { + const id = `user-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + const res = await pool.query( + `INSERT INTO users(id, username, display_name, password_hash, role, active) + VALUES($1,$2,$3,$4,$5,true) + RETURNING id, username, display_name, role, active, created_at`, + [id, input.username, input.displayName ?? null, hashPassword(input.password), input.role], + ); + return mapRow((res.rows as Array>)[0]!); +} + +export interface UpdateUserInput { + displayName?: string | null; + role?: UserRole; + active?: boolean; +} + +/** 更新用户的显示名/角色/启用状态。 */ +export async function updateUser(pool: pg.Pool, id: string, patch: UpdateUserInput): Promise { + const sets: string[] = []; + const vals: unknown[] = []; + let i = 1; + if (patch.displayName !== undefined) { sets.push(`display_name = $${i++}`); vals.push(patch.displayName); } + if (patch.role !== undefined) { sets.push(`role = $${i++}`); vals.push(patch.role); } + if (patch.active !== undefined) { sets.push(`active = $${i++}`); vals.push(patch.active); } + if (sets.length === 0) { + const cur = await pool.query('SELECT id, username, display_name, role, active, created_at FROM users WHERE id=$1', [id]); + const r = (cur.rows as Array>)[0]; + return r ? mapRow(r) : null; + } + sets.push('updated_at = now()'); + vals.push(id); + const res = await pool.query( + `UPDATE users SET ${sets.join(', ')} WHERE id = $${i} RETURNING id, username, display_name, role, active, created_at`, + vals, + ); + const r = (res.rows as Array>)[0]; + return r ? mapRow(r) : null; +} + +/** 重置/修改用户密码。 */ +export async function setUserPassword(pool: pg.Pool, id: string, password: string): Promise { + await pool.query('UPDATE users SET password_hash=$1, updated_at=now() WHERE id=$2', [hashPassword(password), id]); +} + +/** 删除用户。 */ +export async function deleteUser(pool: pg.Pool, id: string): Promise { + await pool.query('DELETE FROM users WHERE id=$1', [id]); +} + +/** 统计用户数。 */ +export async function countUsers(pool: pg.Pool): Promise { + const res = await pool.query('SELECT count(*)::int AS n FROM users'); + return Number((res.rows as Array<{ n: number }>)[0]?.n ?? 0); +} + +/** 首次启动兜底:用户表为空时创建默认账号(密码 123456,请尽快修改)。 */ +export async function ensureSeedUsers(pool: pg.Pool): Promise { + if ((await countUsers(pool)) > 0) return; + const seeds: CreateUserInput[] = [ + { username: '系统账号', displayName: '系统管理员', password: '123456', role: '系统管理员' }, + { username: '管理账号', displayName: '默认管理层', password: '123456', role: '管理层' }, + { username: '风控账号', displayName: '默认风控', password: '123456', role: '风控' }, + { username: '销售账号', displayName: '默认销售', password: '123456', role: '商务/销售' }, + ]; + for (const s of seeds) { + await createUser(pool, s).catch(() => undefined); + } +} diff --git a/src/server/index.ts b/src/server/index.ts index cd245ea..dadc55d 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -62,6 +62,16 @@ import { getDraft, upsertDraft, deleteDraft, + listUsers, + getUserByUsername, + createUser, + updateUser, + setUserPassword, + deleteUser, + verifyPassword, + ensureSeedUsers, + USER_ROLES, + type UserRole, getSetting, setSetting, loadAllRegionRates, @@ -126,14 +136,32 @@ app.get('/api/health', (c) => c.json({ status: 'ok', version: '0.1.0' })); /** * POST /api/auth/login - * 登录签发 JWT(生产)。演示模式(未配置 AUTH_SECRET)下返回空 token,前端沿用本地登录。 + * 登录:有数据库时按 用户名+密码 校验用户表;无数据库时回退演示模式(仅按角色签发)。 + * 返回 JWT(未配置 AUTH_SECRET 时 token 为空,前端仍可凭返回的角色进入演示)。 */ app.post('/api/auth/login', async (c) => { - const body = await c.req.json<{ username?: string; role?: string }>().catch(() => ({}) as { username?: string; role?: string }); + const body = await c.req.json<{ username?: string; password?: string; role?: string }>().catch(() => ({}) as { username?: string; password?: string; role?: string }); const username = (body.username ?? '').trim(); + const password = body.password ?? ''; + + if (username === '') { + return c.json({ error: '请输入用户名' }, 400); + } + + // 有数据库:以用户表为准校验账号密码。 + if (pool !== null) { + const user = await getUserByUsername(pool, username); + if (user === null || !user.active || !verifyPassword(password, user.passwordHash)) { + return c.json({ error: '用户名或密码错误,或账号已停用' }, 401); + } + const token = issueToken(user.username, user.role as AuthRole); + return c.json({ token, username: user.username, role: user.role, displayName: user.displayName }); + } + + // 无数据库(演示模式):按传入角色签发,兼容旧行为。 const role = body.role as AuthRole | undefined; - if (username === '' || (role !== '商务/销售' && role !== '风控' && role !== '管理层')) { - return c.json({ error: '用户名或角色无效' }, 400); + if (role !== '商务/销售' && role !== '风控' && role !== '管理层' && role !== '系统管理员') { + return c.json({ error: '演示模式需提供合法角色' }, 400); } const token = issueToken(username, role); return c.json({ token, username, role }); @@ -1193,6 +1221,71 @@ app.delete('/api/drafts/:id', async (c) => { return c.json({ deleted: true }); }); +/* ------------------------------------------------------------------ * + * 用户管理(系统管理员):多用户与角色维护。 + * ------------------------------------------------------------------ */ + +/** 列出全部用户(系统管理员)。 */ +app.get('/api/users', requireRole('系统管理员'), async (c) => { + if (pool === null) return c.json([]); + return c.json(await listUsers(pool)); +}); + +/** 新增用户(系统管理员)。 */ +app.post('/api/users', requireRole('系统管理员'), async (c) => { + if (pool === null) return c.json({ error: '未配置数据库' }, 400); + const body = await c.req.json<{ username?: string; displayName?: string; password?: string; role?: string }>(); + const username = (body.username ?? '').trim(); + const password = body.password ?? ''; + const role = body.role as UserRole | undefined; + if (username === '' || password.length < 6) { + return c.json({ error: '用户名必填,密码至少 6 位' }, 400); + } + if (role === undefined || !USER_ROLES.includes(role)) { + return c.json({ error: '非法角色' }, 400); + } + const exists = await getUserByUsername(pool, username); + if (exists !== null) { + return c.json({ error: '用户名已存在' }, 409); + } + const user = await createUser(pool, { username, displayName: body.displayName ?? null, password, role }); + return c.json(user); +}); + +/** 更新用户显示名/角色/启用状态(系统管理员)。 */ +app.put('/api/users/:id', requireRole('系统管理员'), async (c) => { + if (pool === null) return c.json({ error: '未配置数据库' }, 400); + const id = c.req.param('id') as string; + const body = await c.req.json<{ displayName?: string | null; role?: string; active?: boolean }>(); + const patch: { displayName?: string | null; role?: UserRole; active?: boolean } = {}; + if (body.displayName !== undefined) patch.displayName = body.displayName; + if (body.role !== undefined) { + if (!USER_ROLES.includes(body.role as UserRole)) return c.json({ error: '非法角色' }, 400); + patch.role = body.role as UserRole; + } + if (body.active !== undefined) patch.active = body.active; + const updated = await updateUser(pool, id, patch); + if (updated === null) return c.json({ error: '用户不存在' }, 404); + return c.json(updated); +}); + +/** 重置用户密码(系统管理员)。 */ +app.post('/api/users/:id/password', requireRole('系统管理员'), async (c) => { + if (pool === null) return c.json({ error: '未配置数据库' }, 400); + const id = c.req.param('id') as string; + const body = await c.req.json<{ password?: string }>(); + if ((body.password ?? '').length < 6) return c.json({ error: '密码至少 6 位' }, 400); + await setUserPassword(pool, id, body.password as string); + return c.json({ reset: true }); +}); + +/** 删除用户(系统管理员)。 */ +app.delete('/api/users/:id', requireRole('系统管理员'), async (c) => { + if (pool === null) return c.json({ error: '未配置数据库' }, 400); + await deleteUser(pool, c.req.param('id') as string); + return c.json({ deleted: true }); +}); + /** * GET /api/region-rates/engine-defaults * 引擎内置默认费率(全国 + 上海/北京/广东),作为维护对照基准。 @@ -2127,6 +2220,7 @@ async function start(): Promise { const arc = await pg.loadArchived(); for (const [id, v] of arc) archivedById.set(id, v); calibratedTargetBase = (await getSetting(pool, 'targetMarginBase')) ?? null; + await ensureSeedUsers(pool); console.log(`PostgreSQL 持久化已启用,已加载 ${store.getAll().length} 条评估记录`); } catch (err) { console.error('PostgreSQL 连接失败,回退到进程内存储:', err instanceof Error ? err.message : err); diff --git a/web/src/App.tsx b/web/src/App.tsx index 0699bdd..f7fb43d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -11,6 +11,7 @@ import { Login } from './pages/Login.js'; import { RateManagement } from './pages/RateManagement.js'; import { RedlineManagement } from './pages/RedlineManagement.js'; import { CustomerManagement } from './pages/CustomerManagement.js'; +import { UserManagement } from './pages/UserManagement.js'; /** 路由守卫:未登录重定向到登录页。 */ function ProtectedRoute(): JSX.Element { @@ -49,6 +50,10 @@ export function App(): JSX.Element { }> } /> + {/* 用户管理:系统管理员 */} + }> + } /> + diff --git a/web/src/__tests__/__snapshots__/visual-regression.test.tsx.snap b/web/src/__tests__/__snapshots__/visual-regression.test.tsx.snap index d49d8d9..6c68211 100644 --- a/web/src/__tests__/__snapshots__/visual-regression.test.tsx.snap +++ b/web/src/__tests__/__snapshots__/visual-regression.test.tsx.snap @@ -12,9 +12,9 @@ exports[`可视化回归基线 — 全套图表看板(Req 19.1 / 20.3) > 看 exports[`可视化回归基线 — 全套图表看板(Req 19.1 / 20.3) > 看板 markup 快照:Light 主题 / MobileLayout(375px) 1`] = `"
风险总分
60
  • Risk_Score(0–100)
  • Risk_Grade:高
风险等级
Top N 关键风险
020406080财务 / 现金流合规 / 资质8065
  • 关键风险(维度/指标)
  • 得分
  • 财务 / 现金流(得分 80):现金流紧张
  • 合规 / 资质(得分 65):资质不全
"`; -exports[`可视化回归基线 — 关键页面(Req 19.1 / 25.x) > App 首屏 markup 快照(Dark 主题) 1`] = `"

外包项目风险评估

智能风险评估平台

点击角色快速登录

"`; +exports[`可视化回归基线 — 关键页面(Req 19.1 / 25.x) > App 首屏 markup 快照(Dark 主题) 1`] = `"

外包项目风险评估

智能风险评估平台

点击角色快速登录

"`; -exports[`可视化回归基线 — 关键页面(Req 19.1 / 25.x) > App 首屏 markup 快照(Light 主题) 1`] = `"

外包项目风险评估

智能风险评估平台

点击角色快速登录

"`; +exports[`可视化回归基线 — 关键页面(Req 19.1 / 25.x) > App 首屏 markup 快照(Light 主题) 1`] = `"

外包项目风险评估

智能风险评估平台

点击角色快速登录

"`; exports[`可视化回归基线 — 关键页面(Req 19.1 / 25.x) > 默认视图 markup 快照:商务/销售(Dark 主题) 1`] = `"

商务/销售视图

"`; diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 4e48093..907e47d 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -799,6 +799,51 @@ export async function deleteDraftApi(id: string): Promise { await fetch(`${API_BASE}/api/drafts/${encodeURIComponent(id)}`, { method: 'DELETE', headers: authHeader() }); } +/** 用户角色。 */ +export type UserRole = '商务/销售' | '风控' | '管理层' | '系统管理员'; + +/** 用户账号(不含密码)。 */ +export interface UserItem { + id: string; + username: string; + displayName: string | null; + role: UserRole; + active: boolean; + createdAt: string; +} + +/** 列出全部用户(系统管理员)。 */ +export async function listUsers(): Promise { + return request('GET', '/api/users'); +} + +/** 新增用户。 */ +export async function createUserApi(input: { username: string; displayName?: string; password: string; role: UserRole }): Promise { + return request('POST', '/api/users', input); +} + +/** 更新用户显示名/角色/启用状态。 */ +export async function updateUserApi(id: string, patch: { displayName?: string | null; role?: UserRole; active?: boolean }): Promise { + const res = await fetch(`${API_BASE}/api/users/${encodeURIComponent(id)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', ...authHeader() }, + body: JSON.stringify(patch), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new ApiError(res.status, typeof data.error === 'string' ? data.error : `HTTP ${res.status}`); + return data as UserItem; +} + +/** 重置用户密码。 */ +export async function resetUserPasswordApi(id: string, password: string): Promise { + await request('POST', `/api/users/${encodeURIComponent(id)}/password`, { password }); +} + +/** 删除用户。 */ +export async function deleteUserApi(id: string): Promise { + await fetch(`${API_BASE}/api/users/${encodeURIComponent(id)}`, { method: 'DELETE', headers: authHeader() }); +} + /** 方案对比。 */ export interface ScenarioItem { id: string; diff --git a/web/src/app/AppShell.tsx b/web/src/app/AppShell.tsx index d01e2f6..3907734 100644 --- a/web/src/app/AppShell.tsx +++ b/web/src/app/AppShell.tsx @@ -44,6 +44,7 @@ const ROLE_STYLE: Record = { '商务/销售': { bg: 'rgba(16, 128, 61, 0.12)', fg: '#15803D' }, '风控': { bg: 'rgba(180, 83, 9, 0.12)', fg: '#B45309' }, '管理层': { bg: 'rgba(79, 70, 229, 0.12)', fg: '#4F46E5' }, + '系统管理员': { bg: 'rgba(8, 145, 178, 0.12)', fg: '#0891B2' }, }; export function AppShell(): JSX.Element { @@ -179,6 +180,12 @@ export function AppShell(): JSX.Element { )} + {role === '系统管理员' && ( + navigate('/users')}> + 用户管理 + + )} +
{ + const handleSubmit = async (e: React.FormEvent): Promise => { e.preventDefault(); clearError(); - const ok = login(username, password); + const ok = await login(username, password); if (ok) { navigate('/'); } @@ -109,7 +109,7 @@ export function Login(): JSX.Element {
-
+ { void handleSubmit(e); }} style={{ display: 'flex', flexDirection: 'column', gap: `${space(3)}px` }}>
+ ); +} + +const tds: React.CSSProperties = { padding: `${space(2)}px ${space(3)}px`, borderBottom: '1px solid var(--color-border-default)', fontSize: '14px' }; +function linkBtn(color: string): React.CSSProperties { + return { cursor: 'pointer', border: 'none', background: 'none', color, fontWeight: 600, fontSize: '12px' }; +} +function Field({ label, children }: { label: string; children: React.ReactNode }): JSX.Element { + return
{children}
; +} diff --git a/web/src/stores/authStore.ts b/web/src/stores/authStore.ts index c4679d7..03b5153 100644 --- a/web/src/stores/authStore.ts +++ b/web/src/stores/authStore.ts @@ -9,34 +9,34 @@ import { create } from 'zustand'; import { API_BASE } from '../api/client.js'; /** 登录用户角色。 */ -export type AuthRole = '商务/销售' | '风控' | '管理层'; +export type AuthRole = '商务/销售' | '风控' | '管理层' | '系统管理员'; -/** 测试账号。 */ +/** 快速登录用的种子账号(密码由后端用户表校验;这里仅用于一键填充)。 */ export const TEST_ACCOUNTS: readonly { readonly username: string; readonly password: string; readonly role: AuthRole; - readonly aliases: readonly string[]; }[] = [ - { username: '销售账号', password: '123456', role: '商务/销售', aliases: ['sales'] }, - { username: '风控账号', password: '123456', role: '风控', aliases: ['risk'] }, - { username: '管理账号', password: '123456', role: '管理层', aliases: ['mgmt'] }, + { username: '销售账号', password: '123456', role: '商务/销售' }, + { username: '风控账号', password: '123456', role: '风控' }, + { username: '管理账号', password: '123456', role: '管理层' }, + { username: '系统账号', password: '123456', role: '系统管理员' }, ]; /** 认证状态。 */ export interface AuthState { readonly isAuthenticated: boolean; - readonly user: { username: string; role: AuthRole } | null; + readonly user: { username: string; role: AuthRole; displayName?: string } | null; readonly error: string | null; - login(username: string, password: string): boolean; + login(username: string, password: string): Promise; logout(): void; clearError(): void; } const STORAGE_KEY = 'risk-agent-auth'; -function loadFromStorage(): { isAuthenticated: boolean; user: { username: string; role: AuthRole } | null } { +function loadFromStorage(): { isAuthenticated: boolean; user: AuthState['user'] } { try { const raw = localStorage.getItem(STORAGE_KEY); if (raw === null) return { isAuthenticated: false, user: null }; @@ -47,7 +47,7 @@ function loadFromStorage(): { isAuthenticated: boolean; user: { username: string } } -function saveToStorage(user: { username: string; role: AuthRole } | null): void { +function saveToStorage(user: AuthState['user']): void { if (user === null) { localStorage.removeItem(STORAGE_KEY); } else { @@ -62,30 +62,29 @@ export const useAuthStore = create((set) => ({ user: initial.user, error: null, - login: (username, password) => { - const account = TEST_ACCOUNTS.find( - (a) => (a.username === username || a.aliases.includes(username)) && a.password === password, - ); - if (account === undefined) { - set({ error: '用户名或密码错误' }); + login: async (username, password) => { + try { + const res = await fetch(`${API_BASE}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: username.trim(), password }), + }); + const d = await res.json().catch(() => ({})); + if (!res.ok) { + set({ error: typeof d.error === 'string' ? d.error : '用户名或密码错误' }); + return false; + } + const role = d.role as AuthRole; + const user = { username: String(d.username ?? username), role, ...(d.displayName ? { displayName: String(d.displayName) } : {}) }; + saveToStorage(user); + if (typeof d.token === 'string' && d.token !== '') localStorage.setItem('risk-agent-token', d.token); + else localStorage.removeItem('risk-agent-token'); + set({ isAuthenticated: true, user, error: null }); + return true; + } catch { + set({ error: '无法连接服务器,请稍后重试' }); return false; } - const user = { username: account.username, role: account.role }; - saveToStorage(user); - // 生产 RBAC:向后端换取 JWT 并保存(演示模式后端返回空 token,不影响本地登录)。 - void fetch(`${API_BASE}/api/auth/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username: account.username, role: account.role }), - }) - .then((r) => r.json()) - .then((d: { token?: string }) => { - if (typeof d.token === 'string' && d.token !== '') localStorage.setItem('risk-agent-token', d.token); - else localStorage.removeItem('risk-agent-token'); - }) - .catch(() => undefined); - set({ isAuthenticated: true, user, error: null }); - return true; }, logout: () => {