feat(users): Adding the profile page and the TOTP connection

Added user profile page (/profile) with display name, password, and TOTP management
Implemented TOTP authentication flow in the login process
Added backend APIs for changing display name and password
Made user display names unique in the database
Removed the entire icons service (server, routes, and Docker configuration)
Added collision-avoidance logic for duplicate display names during user creation
This commit is contained in:
Raphaël 2025-12-10 18:09:53 +01:00 committed by GitHub
commit 492647b817
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
75 changed files with 3555 additions and 1497 deletions

View file

@ -25,6 +25,6 @@
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.19.1"
"@types/node": "^22.19.2"
}
}

View file

@ -14,6 +14,7 @@ const kRouteAuthDone = Symbol('shared-route-auth-done');
type AuthedUser = {
id: UserId;
name: string;
guest: boolean;
};
declare module 'fastify' {
@ -118,7 +119,7 @@ export const authPlugin = fp<{ onlySchema?: boolean }>(async (fastify, { onlySch
.clearCookie('token', { path: '/' })
.makeResponse(401, 'notLoggedIn', 'auth.noUser');
}
req.authUser = { id: user.id, name: user.display_name };
req.authUser = { id: user.id, name: user.name, guest: user.guest };
}
catch {
return res

View file

@ -18,7 +18,7 @@ Project Transcendance {
Table user {
id text [PK, not null]
login text [unique]
name text [not null]
name text [not null, unique]
password text [null, Note: "If password is NULL, this means that the user is created through OAUTH2 or guest login"]
otp text [null, Note: "If otp is NULL, then the user didn't configure 2FA"]
guest integer [not null, default: 0]

View file

@ -1,7 +1,7 @@
CREATE TABLE IF NOT EXISTS user (
id TEXT PRIMARY KEY NOT NULL,
login TEXT UNIQUE,
name TEXT NOT NULL,
name TEXT NOT NULL UNIQUE,
password TEXT,
otp TEXT,
guest INTEGER NOT NULL DEFAULT 0,

View file

@ -3,6 +3,7 @@ import { Otp } from '@shared/auth';
import { isNullish } from '@shared/utils';
import * as bcrypt from 'bcrypt';
import { UUID, newUUID } from '@shared/utils/uuid';
import { SqliteError } from 'better-sqlite3';
// never use this directly
@ -20,6 +21,10 @@ export interface IUserDb extends Database {
getAllUserFromProvider(provider: string): User[] | undefined,
getAllUsers(this: IUserDb): User[] | undefined,
updateDisplayName(id: UserId, new_name: string): boolean,
getUserFromDisplayName(name: string): User | undefined,
};
export const UserImpl: Omit<IUserDb, keyof Database> = {
@ -159,6 +164,24 @@ export const UserImpl: Omit<IUserDb, keyof Database> = {
const req = this.prepare('SELECT * FROM user WHERE oauth2 = @oauth2').get({ oauth2: `${provider}:${unique}` }) as Partial<User> | undefined;
return userFromRow(req);
},
updateDisplayName(this: IUserDb, id: UserId, new_name: string): boolean {
try {
this.prepare('UPDATE OR FAIL user SET name = @new_name WHERE id = @id').run({ id, new_name });
return true;
}
catch (e) {
if (e instanceof SqliteError) {
if (e.code === 'SQLITE_CONSTRAINT_UNIQUE') return false;
}
throw e;
}
},
getUserFromDisplayName(this: IUserDb, name: string) {
const res = this.prepare('SELECT * FROM user WHERE name = @name LIMIT 1').get({ name }) as User | undefined;
return userFromRow(res);
},
};
export type UserId = UUID;
@ -170,7 +193,7 @@ export type User = {
readonly password?: string;
readonly otp?: string;
readonly guest: boolean;
// will be split/merged from the `provider` column
// will be split/merged from the `oauth2` column
readonly provider_name?: string;
readonly provider_unique?: string;
};
@ -207,7 +230,7 @@ async function hashPassword(
*
* @returns The user if it exists, undefined otherwise
*/
export function userFromRow(row?: Partial<Omit<User, 'provider_name' | 'provider_unique'> & { provider?: string }>): User | undefined {
export function userFromRow(row?: Partial<Omit<User, 'provider_name' | 'provider_unique'> & { oauth2?: string }>): User | undefined {
if (isNullish(row)) return undefined;
if (isNullish(row.id)) return undefined;
if (isNullish(row.name)) return undefined;
@ -216,9 +239,9 @@ export function userFromRow(row?: Partial<Omit<User, 'provider_name' | 'provider
let provider_name = undefined;
let provider_unique = undefined;
if (row.provider) {
const splitted = row.provider.split(':', 1);
if (splitted.length != 2) { return undefined; }
if (row.oauth2) {
const splitted = row.oauth2.split(/:(.*)/);
if (splitted.length != 3) { return undefined; }
provider_name = splitted[0];
provider_unique = splitted[1];
}