auth: sign-in ways, sign-in mode, and the admin who keeps a password (S1)

Spec 12 S1: migration 0012 (password_hash nullable, user_identities, sessions.method and the
OpenID token columns), auth.mode (only "password" until S2), GET /api/auth/methods, login body
{login, password} (email still accepted), signIn on users and /api/auth/me, a Signs in with
column on Users. The last active admin with a password can't be deleted, disabled or demoted.
This commit is contained in:
iamdoubz committed 2026-10-25 13:30:58 -05:00
1 parent ba293f8703
commit 3a9423af1f
19 files changed
+4847 -43

No files matched your search

+2
View File
@@ -32,6 +32,7 @@ export async function createSession(
userId: string,
ip: string | null,
userAgent: string | null,
method: 'password' | 'ldap' | 'oidc' = 'password',
): Promise<string> {
const token = randomBytes(32).toString('base64url');
await db.insert(sessions).values({
@@ -40,6 +41,7 @@ export async function createSession(
expiresAt: new Date(Date.now() + LIFETIME),
ip,
userAgent: userAgent?.slice(0, 500) ?? null,
method,
});
return token;
}
+21
View File
@@ -0,0 +1,21 @@
CREATE TYPE "public"."identity_kind" AS ENUM('oidc', 'ldap');--> statement-breakpoint
CREATE TABLE "user_identities" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"kind" "identity_kind" NOT NULL,
"subject" text NOT NULL,
"email" text,
"groups" text[] DEFAULT '{}' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"last_used_at" timestamp with time zone,
CONSTRAINT "user_identities_kind_subject_unique" UNIQUE("kind","subject"),
CONSTRAINT "user_identities_userId_kind_unique" UNIQUE("user_id","kind")
);
--> statement-breakpoint
ALTER TABLE "users" ALTER COLUMN "password_hash" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "sessions" ADD COLUMN "method" text DEFAULT 'password' NOT NULL;--> statement-breakpoint
ALTER TABLE "sessions" ADD COLUMN "idp_refresh_enc" text;--> statement-breakpoint
ALTER TABLE "sessions" ADD COLUMN "idp_id_token_enc" text;--> statement-breakpoint
ALTER TABLE "sessions" ADD COLUMN "idp_checked_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "user_identities" ADD CONSTRAINT "user_identities_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_method_check" CHECK ("sessions"."method" in ('password', 'ldap', 'oidc'));
File diff suppressed because it is too large. Load diff
@@ -85,6 +85,13 @@
"when": 1792788393381,
"tag": "0011_contrack_call_log",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1792951905226,
"tag": "0012_sso",
"breakpoints": true
}
]
}
+35 -2
View File
@@ -19,6 +19,7 @@ import {
smallint,
text,
timestamp,
unique,
uuid,
} from 'drizzle-orm/pg-core';
@@ -53,7 +54,8 @@ export const users = pgTable('users', {
id: id(),
email: citext().notNull().unique(),
displayName: text().notNull(),
passwordHash: text().notNull(),
/** argon2id; null: no password, signs in with OpenID only (12 §3.3). */
passwordHash: text(),
role: userRole().notNull().default('user'),
isActive: boolean().notNull().default(true),
totpSecretEnc: text(),
@@ -83,9 +85,40 @@ export const sessions = pgTable(
expiresAt: tstz().notNull(),
ip: inet(),
userAgent: text(),
/** How it was made (12 §2): password, ldap or oidc. */
method: text().notNull().default('password'),
/** OpenID sessions (12 §3.5): refresh token for the leaver check, ID token for signing out. */
idpRefreshEnc: text(),
idpIdTokenEnc: text(),
idpCheckedAt: tstz(),
...timestamps,
},
(t) => [index().on(t.userId)],
(t) => [
index().on(t.userId),
check('sessions_method_check', sql`${t.method} in ('password', 'ldap', 'oidc')`),
],
);
// One more way to sign in to a user (12 §2): a connected OpenID account or directory entry.
export const identityKind = pgEnum('identity_kind', ['oidc', 'ldap']);
export const userIdentities = pgTable(
'user_identities',
{
id: id(),
userId: uuid()
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
kind: identityKind().notNull(),
/** oidc: "<iss> <sub>"; ldap: the entry's UUID attribute. */
subject: text().notNull(),
/** As the provider last said. */
email: text(),
/** oidc: groupsClaim values at the last sign-in (12 §3.4). */
groups: emptyTextArray(),
createdAt: createdAt(),
lastUsedAt: tstz(),
},
(t) => [unique().on(t.kind, t.subject), unique().on(t.userId, t.kind)],
);
export const apiTokens = pgTable('api_tokens', {
+50 -13
View File
@@ -8,7 +8,9 @@ import {
PasswordConfirmSchema,
TotpEnableSchema,
APP_NAME,
type AuthMethods,
type AuthUser,
type SignInWays,
type TotpSetup,
type TotpStatus,
} from '@selfphoneng/shared';
@@ -22,7 +24,8 @@ import {
otpauthUrl,
} from '../auth/totp.ts';
import type { Db } from '../db/client.ts';
import { extensions, users } from '../db/schema.ts';
import { extensions, userIdentities, users } from '../db/schema.ts';
import { getSetting } from '../services/settings.ts';
import {
unauthorized,
rateLimited,
@@ -49,11 +52,28 @@ import {
setSessionCookie,
} from '../auth/sessions.ts';
/** 12 §2: the user's password and connected accounts, from their user_identities rows. */
export const signInWays = (
u: { passwordHash: string | null },
ids: { kind: 'oidc' | 'ldap'; email: string | null }[],
): SignInWays => {
const oidc = ids.find((i) => i.kind === 'oidc');
return {
password: u.passwordHash !== null,
oidc: oidc ? { email: oidc.email } : null,
ldap: ids.some((i) => i.kind === 'ldap'),
};
};
export async function toAuthUser(db: Db, u: CurrentUser): Promise<AuthUser> {
const [ext] = await db
.select({ id: extensions.id, number: extensions.number, name: extensions.name })
.from(extensions)
.where(eq(extensions.userId, u.id));
const ids = await db
.select({ kind: userIdentities.kind, email: userIdentities.email })
.from(userIdentities)
.where(eq(userIdentities.userId, u.id));
return {
id: u.id,
email: u.email,
@@ -61,6 +81,7 @@ export async function toAuthUser(db: Db, u: CurrentUser): Promise<AuthUser> {
role: u.role,
mustChangePassword: u.mustChangePassword,
extension: ext ?? null,
signIn: signInWays(u, ids),
};
}
@@ -118,7 +139,8 @@ export async function authRoutes(app: FastifyInstance) {
const u = await myRow(req.user!.id);
if (u.totpSecretEnc) throw conflict('Two-step sign-in is already on');
// Like turning it off: a stolen session alone can't lock the owner out with its own secret.
if (!(await verifyPassword(u.passwordHash, password))) throw badRequest('Wrong password');
if (!u.passwordHash || !(await verifyPassword(u.passwordHash, password)))
throw badRequest('Wrong password');
const step = checkTotp(secret, code, null);
if (step === null)
throw badRequest('That code doesn’t match. Check the phone’s clock and try again.');
@@ -142,7 +164,8 @@ export async function authRoutes(app: FastifyInstance) {
async function confirmPassword(id: string, body: unknown) {
const { password } = PasswordConfirmSchema.parse(body);
const u = await myRow(id);
if (!(await verifyPassword(u.passwordHash, password))) throw badRequest('Wrong password');
if (!u.passwordHash || !(await verifyPassword(u.passwordHash, password)))
throw badRequest('Wrong password');
return u;
}
@@ -179,15 +202,24 @@ export async function authRoutes(app: FastifyInstance) {
return { recoveryCodes: codes };
});
/** The login page's choices (12 §2). Public: says nothing about any user. */
app.get('/api/auth/methods', async (): Promise<AuthMethods> => ({
mode: await getSetting(db, 'auth.mode'),
ldap: false, // S4
oidc: null, // S2
}));
app.post('/api/auth/login', async (req, reply) => {
const body = LoginSchema.parse(req.body);
if (isLoginBlocked(req.ip, body.email)) throw rateLimited();
const [user] = await db.select().from(users).where(eq(users.email, body.email));
const ok = user?.isActive
? await verifyPassword(user.passwordHash, body.password)
: (await burnVerify(body.password), false);
const { login, ...body } = LoginSchema.parse(req.body);
if (isLoginBlocked(req.ip, login)) throw rateLimited();
const [user] = await db.select().from(users).where(eq(users.email, login));
// No password (OpenID only, 12 §3.3) fails like a wrong one, after the same work.
const ok =
user?.isActive && user.passwordHash
? await verifyPassword(user.passwordHash, body.password)
: (await burnVerify(body.password), false);
if (!user || !ok) {
recordLoginFailure(req.ip, body.email);
recordLoginFailure(req.ip, login);
req.log.warn({ event: 'auth.failed', ip: req.ip }, 'login failed');
await audit(db, {
actorUserId: user?.id,
@@ -195,16 +227,19 @@ export async function authRoutes(app: FastifyInstance) {
action: 'login_failed',
entityType: 'user',
entityId: user?.id,
summary: `Failed login for ${body.email}`,
summary: `Failed login for ${login}`,
});
throw unauthorized('Wrong email or password');
}
// OpenID only (12 §1.1): passwords are for admins, the way in when the provider is down.
if (user.role !== 'admin' && (await getSetting(db, 'auth.mode')) === 'oidc')
throw forbidden('Use the sign-in button instead', 'password_sign_in_off');
// Two-step sign-in (08 §2.4): the password was right; now the code.
if (user.totpSecretEnc) {
if (!body.totp)
throw new HttpError(401, 'totp_required', 'Enter the code from your authenticator app');
if (!(await useSecondFactor(user, body.totp))) {
recordLoginFailure(req.ip, body.email);
recordLoginFailure(req.ip, login);
req.log.warn({ event: 'auth.failed', ip: req.ip }, 'login failed: code');
await audit(db, {
actorUserId: user.id,
@@ -212,7 +247,7 @@ export async function authRoutes(app: FastifyInstance) {
action: 'login_failed',
entityType: 'user',
entityId: user.id,
summary: `Failed login for ${body.email} (wrong authenticator or recovery code)`,
summary: `Failed login for ${login} (wrong authenticator or recovery code)`,
});
throw new HttpError(401, 'totp_invalid', 'That code is wrong or was used already');
}
@@ -252,6 +287,8 @@ export async function authRoutes(app: FastifyInstance) {
app.post('/api/auth/change-password', { preHandler: requireAuth }, async (req) => {
const body = ChangePasswordSchema.parse(req.body);
const user = req.user!;
if (!user.passwordHash)
throw badRequest('You have no password here. Ask an admin to reset it to get one.');
if (!(await verifyPassword(user.passwordHash, body.currentPassword)))
throw badRequest('Current password is wrong');
if (body.newPassword === body.currentPassword)
+4
View File
@@ -145,6 +145,10 @@ export async function settingsRoutes(app: FastifyInstance) {
} else values[key] = SettingsSchemas[key as SettingKey].parse(raw);
}
await checkRefs(db, values);
// 12 §1.1: mixed and oidc need OpenID Connect set up first.
// S2 makes this "auth.oidc set and its Test passed".
if (values['auth.mode'] !== undefined && values['auth.mode'] !== 'password')
throw badRequest('Set up OpenID Connect first');
for (const key of ['ai.llm', 'ai.stt', 'ai.tts'] as const) {
const ep = values[key] as { url: string } | null | undefined;
try {
+42 -9
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// /api/users (04 §7.3). Admin only.
import { and, asc, count, eq, ne } from 'drizzle-orm';
import { and, asc, count, eq, isNotNull, ne } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import {
@@ -10,17 +10,23 @@ import {
type UserDto,
} from '@selfphoneng/shared';
import type { Db } from '../db/client.ts';
import { extensions, users } from '../db/schema.ts';
import { extensions, userIdentities, users } from '../db/schema.ts';
import { badRequest, conflict, notFound } from '../errors.ts';
import { requireAdmin } from '../auth/guards.ts';
import { hashPassword } from '../auth/passwords.ts';
import { deleteUserSessions } from '../auth/sessions.ts';
import { randomAlnum } from '../crypto/secrets.ts';
import { audit } from '../services/audit.ts';
import { signInWays } from './auth.ts';
type UserRow = typeof users.$inferSelect;
type IdentityRow = { userId: string; kind: 'oidc' | 'ldap'; email: string | null };
export const toUserDto = (u: UserRow, extension: UserDto['extension'] = null): UserDto => ({
export const toUserDto = (
u: UserRow,
extension: UserDto['extension'] = null,
ids: IdentityRow[] = [],
): UserDto => ({
id: u.id,
email: u.email,
displayName: u.displayName,
@@ -31,8 +37,20 @@ export const toUserDto = (u: UserRow, extension: UserDto['extension'] = null): U
lastLoginAt: u.lastLoginAt?.toISOString() ?? null,
createdAt: u.createdAt.toISOString(),
extension,
signIn: signInWays(u, ids),
});
/** Connected accounts (12 §2), for one user or all of them. */
const identitiesOf = (db: Db, userId?: string): Promise<IdentityRow[]> =>
db
.select({
userId: userIdentities.userId,
kind: userIdentities.kind,
email: userIdentities.email,
})
.from(userIdentities)
.where(userId ? eq(userIdentities.userId, userId) : undefined);
const extOf = async (db: Db, userId: string) => {
const [e] = await db
.select({ id: extensions.id, number: extensions.number })
@@ -56,13 +74,23 @@ async function getUser(db: Db, id: string): Promise<UserRow> {
return u;
}
/** Throws if removing `id` from the active admins would leave none. */
/**
* Throws if removing `id` from the active admins would leave none with a password: the way in
* when the identity provider is down (12 §1).
*/
async function assertNotLastAdmin(db: Db, id: string) {
const [r] = await db
.select({ n: count() })
.from(users)
.where(and(eq(users.role, 'admin'), eq(users.isActive, true), ne(users.id, id)));
if (!r || r.n === 0) throw conflict('This is the last active admin');
.where(
and(
eq(users.role, 'admin'),
eq(users.isActive, true),
isNotNull(users.passwordHash),
ne(users.id, id),
),
);
if (!r || r.n === 0) throw conflict('This is the last active admin with a password');
}
export async function userRoutes(app: FastifyInstance) {
@@ -76,9 +104,14 @@ export async function userRoutes(app: FastifyInstance) {
.from(users)
.leftJoin(extensions, eq(extensions.userId, users.id))
.orderBy(asc(users.displayName), asc(users.id));
const ids = await identitiesOf(db);
return {
items: rows.map((r) =>
toUserDto(r.u, r.extId && r.extNumber ? { id: r.extId, number: r.extNumber } : null),
toUserDto(
r.u,
r.extId && r.extNumber ? { id: r.extId, number: r.extNumber } : null,
ids.filter((i) => i.userId === r.u.id),
),
),
nextCursor: null,
};
@@ -86,7 +119,7 @@ export async function userRoutes(app: FastifyInstance) {
app.get('/api/users/:id', async (req) => {
const { id } = IdParams.parse(req.params);
return toUserDto(await getUser(db, id), await extOf(db, id));
return toUserDto(await getUser(db, id), await extOf(db, id), await identitiesOf(db, id));
});
app.post('/api/users', async (req, reply) => {
@@ -150,7 +183,7 @@ export async function userRoutes(app: FastifyInstance) {
after: Object.fromEntries(changed.map((k) => [k, after[k]])),
},
});
return toUserDto(after, await extOf(db, id));
return toUserDto(after, await extOf(db, id), await identitiesOf(db, id));
});
app.delete('/api/users/:id', async (req) => {
+3 -3
View File
@@ -15,7 +15,7 @@ const EXPECTED: Record<string, Record<string, string>> = {
id: 'uuid|NO|gen_random_uuid()',
email: 'USER-DEFINED:citext|NO|',
display_name: 'text|NO|',
password_hash: 'text|NO|',
password_hash: 'text|YES|',
role: "USER-DEFINED:user_role|NO|'user'::user_role",
is_active: 'boolean|NO|true',
totp_secret_enc: 'text|YES|',
@@ -115,11 +115,11 @@ describe.skipIf(!hasTestDb)('schema matches spec 02', () => {
beforeAll(async () => ({ db, drop } = await createTestDb()));
afterAll(() => drop?.());
it('creates all 43 tables', async () => {
it('creates all 44 tables', async () => {
const r = await db.execute(
sql`select count(*)::int as n from information_schema.tables where table_schema = 'public'`,
);
expect(r.rows[0]).toEqual({ n: 43 });
expect(r.rows[0]).toEqual({ n: 44 });
});
it.each(Object.keys(EXPECTED))('%s columns', async (table) => {
+110
View File
@@ -0,0 +1,110 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// 12 S1: ways to sign in, the sign-in mode, and the admin with a password kept as the way in.
import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { sessions, userIdentities, users } from '../src/db/schema.ts';
import { resetLoginLimiter } from '../src/auth/login-limiter.ts';
import { setSetting } from '../src/services/settings.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
describe.skipIf(!hasTestDb)('sign-in (12 S1)', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
let admin: string;
beforeAll(async () => {
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
admin = await t.login(ADMIN.email, ADMIN.password);
});
afterAll(() => t?.close());
beforeEach(() => resetLoginLimiter());
const post = (url: string, payload: object, cookie?: string) =>
t.app.inject({ method: 'POST', url, payload, headers: { ...H, ...(cookie && { cookie }) } });
it('GET /api/auth/methods is public and offers passwords only', async () => {
const r = await t.app.inject({ url: '/api/auth/methods' });
expect(r.statusCode).toBe(200);
expect(r.json()).toEqual({ mode: 'password', ldap: false, oidc: null });
});
it('signs in with `login`, and still with `email`; the session says how', async () => {
const r = await post('/api/auth/login', { login: ADMIN.email, password: ADMIN.password });
expect(r.statusCode).toBe(200);
expect(r.json().user.signIn).toEqual({ password: true, oidc: null, ldap: false });
const rows = await t.db.select({ method: sessions.method }).from(sessions);
expect(rows.every((s) => s.method === 'password')).toBe(true);
});
it('a user without a password fails like a wrong password, and has none to change', async () => {
const u = await t.addUser('nopw@example.com', 'temporary-pass-1');
const cookie = await t.login('nopw@example.com', 'temporary-pass-1');
await t.db.update(users).set({ passwordHash: null }).where(eq(users.id, u.id));
const r = await post('/api/auth/login', { login: 'nopw@example.com', password: 'anything' });
expect(r.statusCode).toBe(401);
expect(r.json().error.message).toBe('Wrong email or password');
const c = await post(
'/api/auth/change-password',
{ currentPassword: 'x', newPassword: 'a-new-password-1' },
cookie,
);
expect(c.statusCode).toBe(400);
});
it('users show their connected accounts', async () => {
const u = await t.addUser('linked@example.com', 'linked-password-1');
await t.db
.insert(userIdentities)
.values({ userId: u.id, kind: 'oidc', subject: 'https://kc/realms/x abc', email: 'l@kc' });
const r = await t.app.inject({ url: `/api/users/${u.id}`, headers: { cookie: admin } });
expect(r.json().signIn).toEqual({ password: true, oidc: { email: 'l@kc' }, ldap: false });
});
it('auth.mode can only be password until OpenID Connect is set up', async () => {
const r = t.app.inject({
method: 'PATCH',
url: '/api/settings',
payload: { 'auth.mode': 'mixed' },
headers: { ...H, cookie: admin },
});
expect((await r).statusCode).toBe(400);
});
it('OpenID-only mode refuses passwords, except for admins', async () => {
await t.addUser('plain@example.com', 'plain-password-1');
await setSetting(t.db, 'auth.mode', 'oidc');
try {
const r = await post('/api/auth/login', {
login: 'plain@example.com',
password: 'plain-password-1',
});
expect(r.statusCode).toBe(403);
expect(r.json().error.code).toBe('password_sign_in_off');
const a = await post('/api/auth/login', { login: ADMIN.email, password: ADMIN.password });
expect(a.statusCode).toBe(200);
} finally {
await setSetting(t.db, 'auth.mode', 'password');
}
});
it('the last active admin with a password stays, even with other admins', async () => {
const [me] = await t.db.select().from(users).where(eq(users.email, ADMIN.email));
const other = await t.addUser('sso-admin@example.com', 'sso-admin-pass-1', 'admin');
await t.db.update(users).set({ passwordHash: null }).where(eq(users.id, other.id));
const demote = await t.app.inject({
method: 'PATCH',
url: `/api/users/${me!.id}`,
payload: { role: 'user' },
headers: { ...H, cookie: admin },
});
expect(demote.statusCode).toBe(409);
expect(demote.json().error.message).toBe('This is the last active admin with a password');
// An admin without a password can go: the one with a password is still there.
const del = await t.app.inject({
method: 'DELETE',
url: `/api/users/${other.id}`,
headers: { ...H, cookie: admin },
});
expect(del.statusCode).toBe(200);
});
});
+2 -2
View File
@@ -149,9 +149,9 @@ async function upload<T>(method: string, url: string, body: Blob): Promise<T> {
export const authApi = {
me: () => api<{ user: AuthUser }>('GET', '/api/auth/me').then((r) => r.user),
login: (email: string, password: string, totp?: string) =>
login: (login: string, password: string, totp?: string) =>
api<{ user: AuthUser }>('POST', '/api/auth/login', {
email,
login,
password,
...(totp ? { totp } : {}),
}).then((r) => r.user),
+6 -6
View File
@@ -4,7 +4,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { Link, useNavigate, useSearchParams } from 'react-router';
import { APP_NAME, LoginSchema } from '@selfphoneng/shared';
import { APP_NAME, LoginFields } from '@selfphoneng/shared';
import { ApiError, authApi } from '../api/client.ts';
import { homeFor } from '../auth/RequireAuth.tsx';
import { Alert, Button, Field, inputClass } from '../components/ui.tsx';
@@ -32,13 +32,13 @@ export function LoginPage() {
// Two-step sign-in: shown once the server says the password was right and a code is needed.
const [needCode, setNeedCode] = useState(false);
const [code, setCode] = useState('');
const form = useForm({ resolver: zodResolver(LoginSchema.omit({ totp: true })) });
const form = useForm({ resolver: zodResolver(LoginFields.omit({ totp: true })) });
const { errors, isSubmitting } = form.formState;
const onSubmit = form.handleSubmit(async ({ email, password }) => {
const onSubmit = form.handleSubmit(async ({ login, password }) => {
setError(undefined);
try {
const user = await authApi.login(email, password, needCode ? code : undefined);
const user = await authApi.login(login, password, needCode ? code : undefined);
qc.setQueryData(['me'], user);
const next = params.get('next');
// Only same-site paths: never redirect to another origin.
@@ -58,11 +58,11 @@ export function LoginPage() {
<AuthCard title="Sign in">
<form onSubmit={onSubmit} className="flex flex-col gap-4" noValidate>
{error && <Alert>{error}</Alert>}
<Field label="Email" error={errors.email?.message}>
<Field label="Email" error={errors.login?.message}>
{(a) => (
<input
{...a}
{...form.register('email')}
{...form.register('login')}
type="email"
autoComplete="username"
className={inputClass}
+10 -1
View File
@@ -151,6 +151,11 @@ function UsersTable({
)}
</span>
);
// 12 §5: every way this user can sign in.
const ways = (u: UserDto) =>
[u.signIn.password && 'Password', u.signIn.oidc && 'OpenID', u.signIn.ldap && 'Directory']
.filter(Boolean)
.join(', ') || '–';
const actions = (u: UserDto) => (
<span className="flex gap-1">
<Button variant="ghost" onClick={() => onEdit(u)} aria-label={`Edit ${u.displayName}`}>
@@ -178,7 +183,9 @@ function UsersTable({
{badge(u)}
</div>
<div className="mt-2 flex items-center justify-between text-xs text-zinc-500">
<span>Last login: {fmtDate(u.lastLoginAt)}</span>
<span>
Last login: {fmtDate(u.lastLoginAt)} · {ways(u)}
</span>
{actions(u)}
</div>
</li>
@@ -191,6 +198,7 @@ function UsersTable({
<th className="py-2 font-medium">Name</th>
<th className="py-2 font-medium">Email</th>
<th className="py-2 font-medium">Role</th>
<th className="py-2 font-medium">Signs in with</th>
<th className="py-2 font-medium">Last login</th>
<th className="py-2 font-medium">
<span className="sr-only">Actions</span>
@@ -204,6 +212,7 @@ function UsersTable({
<td className="py-2 font-medium">{u.displayName}</td>
<td className="py-2">{u.email}</td>
<td className="py-2">{badge(u)}</td>
<td className="py-2">{ways(u)}</td>
<td className="py-2 text-zinc-500">{fmtDate(u.lastLoginAt)}</td>
<td className="py-2 text-right">{actions(u)}</td>
</tr>