All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 2m28s
Co-authored-by: Shaun Campbell <shaun@campbellwireless.net> Reviewed-on: #39 Co-authored-by: AI Agent <ai-agent@campbellwireless.net> Co-committed-by: AI Agent <ai-agent@campbellwireless.net>
196 lines
6.0 KiB
TypeScript
196 lines
6.0 KiB
TypeScript
import { randomUUID } from 'crypto';
|
|
import { db } from './db/index.js';
|
|
import { setLocalCredentialPassword } from './local-credentials.js';
|
|
|
|
export interface AppUser {
|
|
id: string;
|
|
username: string;
|
|
full_name: string;
|
|
email: string | null;
|
|
auth_source: string;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
const DEFAULT_AUTH_SOURCE = 'OIDC - Synology';
|
|
const LOCAL_AUTH_SOURCE = 'Local';
|
|
|
|
const normalizeOptional = (value?: string | null): string | undefined => {
|
|
const trimmed = value?.trim();
|
|
return trimmed ? trimmed : undefined;
|
|
};
|
|
|
|
const normalizeEmail = (value?: string | null): string | null | undefined => {
|
|
if (value === undefined) return undefined;
|
|
if (value === null) return null;
|
|
const trimmed = value.trim();
|
|
return trimmed ? trimmed : null;
|
|
};
|
|
|
|
export function listUsers(): (AppUser & { has_local_credentials: boolean })[] {
|
|
return db
|
|
.all<AppUser & { has_local_credentials: 0 | 1 }>(
|
|
`SELECT u.id, u.username, u.full_name, u.email, u.auth_source, u.created_at, u.updated_at,
|
|
CASE WHEN lc.user_id IS NOT NULL THEN 1 ELSE 0 END AS has_local_credentials
|
|
FROM users u
|
|
LEFT JOIN local_credentials lc ON lc.user_id = u.id
|
|
ORDER BY u.username COLLATE NOCASE`
|
|
)
|
|
.map((row) => ({ ...row, has_local_credentials: row.has_local_credentials === 1 }));
|
|
}
|
|
|
|
export function upsertUserFromAuth(input: {
|
|
id: string;
|
|
username?: string | null;
|
|
fullName?: string | null;
|
|
email?: string | null;
|
|
authSource?: string | null;
|
|
}): AppUser {
|
|
const existing = db.get<AppUser>('SELECT * FROM users WHERE id = ?', [input.id]);
|
|
const username = normalizeOptional(input.username) ?? existing?.username ?? input.id;
|
|
const fullName = normalizeOptional(input.fullName) ?? existing?.full_name ?? '';
|
|
const email = normalizeOptional(input.email) ?? existing?.email ?? null;
|
|
const authSource =
|
|
normalizeOptional(input.authSource) ?? existing?.auth_source ?? DEFAULT_AUTH_SOURCE;
|
|
|
|
if (existing) {
|
|
const setClauses: string[] = [];
|
|
const values: Array<string | null> = [];
|
|
if (username !== existing.username) {
|
|
setClauses.push('username = ?');
|
|
values.push(username);
|
|
}
|
|
if (fullName !== existing.full_name) {
|
|
setClauses.push('full_name = ?');
|
|
values.push(fullName);
|
|
}
|
|
if (email !== existing.email) {
|
|
setClauses.push('email = ?');
|
|
values.push(email);
|
|
}
|
|
if (authSource !== existing.auth_source) {
|
|
setClauses.push('auth_source = ?');
|
|
values.push(authSource);
|
|
}
|
|
if (setClauses.length > 0) {
|
|
setClauses.push("updated_at = datetime('now')");
|
|
values.push(input.id);
|
|
db.run(`UPDATE users SET ${setClauses.join(', ')} WHERE id = ?`, values);
|
|
}
|
|
} else {
|
|
db.run(
|
|
`INSERT INTO users (id, username, full_name, email, auth_source)
|
|
VALUES (?, ?, ?, ?, ?)`,
|
|
[input.id, username, fullName, email, authSource]
|
|
);
|
|
}
|
|
return db.get<AppUser>('SELECT * FROM users WHERE id = ?', [input.id])!;
|
|
}
|
|
|
|
export function upsertUserProfile(
|
|
userId: string,
|
|
fullName: string,
|
|
email?: string | null
|
|
): AppUser {
|
|
return upsertUserFromAuth({ id: userId, fullName, email, authSource: DEFAULT_AUTH_SOURCE });
|
|
}
|
|
|
|
export function updateUser(input: {
|
|
id: string;
|
|
username: string;
|
|
fullName: string;
|
|
email?: string | null;
|
|
}): void {
|
|
const existing = db.get<{ email: string | null }>('SELECT email FROM users WHERE id = ?', [
|
|
input.id
|
|
]);
|
|
const email = normalizeEmail(input.email);
|
|
const nextEmail = email === undefined ? existing?.email ?? null : email;
|
|
db.run(
|
|
`UPDATE users
|
|
SET username = ?, full_name = ?, email = ?, updated_at = datetime('now')
|
|
WHERE id = ?`,
|
|
[input.username.trim(), input.fullName.trim(), nextEmail, input.id]
|
|
);
|
|
syncSelfProfile(input.id, input.fullName, nextEmail);
|
|
}
|
|
|
|
export function deleteUser(id: string): void {
|
|
db.run('DELETE FROM users WHERE id = ?', [id]);
|
|
}
|
|
|
|
export async function createLocalUser(input: {
|
|
username: string;
|
|
fullName: string;
|
|
email?: string | null;
|
|
password: string;
|
|
}): Promise<AppUser> {
|
|
const username = input.username?.trim() ?? '';
|
|
const fullName = input.fullName?.trim() ?? '';
|
|
if (!username || !fullName) {
|
|
throw new Error('Username and full name are required');
|
|
}
|
|
const email = normalizeEmail(input.email);
|
|
const existingUsername = db.get<{ id: string }>(
|
|
'SELECT id FROM users WHERE lower(username) = lower(?)',
|
|
[username]
|
|
);
|
|
if (existingUsername) {
|
|
throw new Error('Username already exists');
|
|
}
|
|
if (email) {
|
|
const existingEmail = db.get<{ id: string }>(
|
|
'SELECT id FROM users WHERE lower(email) = lower(?)',
|
|
[email]
|
|
);
|
|
if (existingEmail) {
|
|
throw new Error('Email already exists');
|
|
}
|
|
}
|
|
const id = randomUUID();
|
|
db.run(
|
|
`INSERT INTO users (id, username, full_name, email, auth_source)
|
|
VALUES (?, ?, ?, ?, ?)`,
|
|
[id, username, fullName, email ?? null, LOCAL_AUTH_SOURCE]
|
|
);
|
|
await setLocalCredentialPassword(id, input.password);
|
|
return db.get<AppUser>('SELECT * FROM users WHERE id = ?', [id])!;
|
|
}
|
|
|
|
export async function setLocalPasswordForUser(userId: string, password: string): Promise<void> {
|
|
const id = userId?.trim() ?? '';
|
|
if (!id) {
|
|
throw new Error('User id required');
|
|
}
|
|
const existing = db.get<{ id: string }>('SELECT id FROM users WHERE id = ?', [id]);
|
|
if (!existing) {
|
|
throw new Error('User not found');
|
|
}
|
|
await setLocalCredentialPassword(id, password);
|
|
}
|
|
|
|
function syncSelfProfile(userId: string, fullName: string, email?: string | null): void {
|
|
const profile = db.get<{ id: string; first_name: string; last_name: string; email: string | null }>(
|
|
`SELECT id, first_name, last_name, email FROM people WHERE user_id = ? AND is_self = 1 LIMIT 1`,
|
|
[userId]
|
|
);
|
|
if (!profile) return;
|
|
|
|
const trimmed = fullName.trim();
|
|
let firstName = profile.first_name;
|
|
let lastName = profile.last_name;
|
|
if (trimmed) {
|
|
const parts = trimmed.split(/\s+/);
|
|
firstName = parts[0] ?? profile.first_name;
|
|
if (parts.length > 1) {
|
|
lastName = parts.slice(1).join(' ');
|
|
}
|
|
}
|
|
const nextEmail = email === undefined ? profile.email : email;
|
|
db.run(
|
|
`UPDATE people SET first_name = ?, last_name = ?, email = ?, updated_at = datetime('now')
|
|
WHERE id = ?`,
|
|
[firstName, lastName, nextEmail, profile.id]
|
|
);
|
|
}
|