feat(auth): add email verification to registration flow
Some checks failed
CI / Test backend (pull_request) Failing after 11s
CI / Check ui (pull_request) Failing after 11s
CI / Docker / backend (pull_request) Has been skipped
CI / Docker / runner (pull_request) Has been skipped
CI / Docker / ui (pull_request) Has been skipped
CI / Docker / caddy (pull_request) Successful in 2m53s
Release / Test backend (push) Successful in 19s
Release / Check ui (push) Successful in 33s
Release / Docker / caddy (push) Failing after 1m26s
Release / Docker / ui (push) Failing after 11s
Release / Docker / backend (push) Successful in 2m2s
Release / Docker / runner (push) Successful in 2m24s
Release / Gitea Release (push) Has been skipped

- Add email/email_verified/verification_token/verification_token_exp fields
  to app_users PocketBase schema (pb-init-v3.sh)
- Add SMTP env vars to UI service in docker-compose.yml
- New email.ts: raw TLS SMTP mailer via Node tls module, sendVerificationEmail()
- createUser() now takes email param, stores verification token (24h TTL)
- loginUser() throws 'Email not verified' when email_verified is false
- New /verify-email route: validates token, verifies user, auto-logs in
- Login page: email field in register form, check-inbox state after register
- /api/auth/register (iOS): returns { pending_verification, email } instead of token
- Add pb.libnovel.cc and storage.libnovel.cc Caddy virtual hosts for homelab runner
- Add homelab runner docker-compose and libnovel.sh helper script
This commit is contained in:
Admin
2026-03-24 20:18:24 +05:00
parent 424f2c5e16
commit 920ac0d41b
13 changed files with 759 additions and 211 deletions

View File

@@ -63,6 +63,10 @@ export interface User {
role: string;
created: string;
avatar_url?: string;
email?: string;
email_verified?: boolean;
verification_token?: string;
verification_token_exp?: string;
}
// ─── Auth token cache ─────────────────────────────────────────────────────────
@@ -486,21 +490,52 @@ export async function getUserByUsername(username: string): Promise<User | null>
}
/**
* Create a new user with a hashed password. Throws if username already exists.
* Look up a user by email. Returns null if not found.
*/
export async function createUser(username: string, password: string, role = 'user'): Promise<User> {
export async function getUserByEmail(email: string): Promise<User | null> {
return listOne<User>('app_users', `email="${email.replace(/"/g, '\\"')}"`);
}
/**
* Look up a user by verification token. Returns null if not found.
*/
export async function getUserByVerificationToken(token: string): Promise<User | null> {
return listOne<User>('app_users', `verification_token="${token.replace(/"/g, '\\"')}"`);
}
/**
* Create a new user with a hashed password. Throws if username already exists.
* Stores email + verification token but does NOT log the user in.
*/
export async function createUser(
username: string,
password: string,
email: string,
role = 'user'
): Promise<User> {
log.info('pocketbase', 'createUser: checking for existing username', { username });
const existing = await getUserByUsername(username);
if (existing) {
log.warn('pocketbase', 'createUser: username already taken', { username });
throw new Error('Username already taken');
}
const existingEmail = await getUserByEmail(email);
if (existingEmail) {
log.warn('pocketbase', 'createUser: email already in use', { email });
throw new Error('Email already in use');
}
const password_hash = hashPassword(password);
log.info('pocketbase', 'createUser: inserting new user', { username, role });
const verification_token = randomBytes(32).toString('hex');
const verification_token_exp = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
log.info('pocketbase', 'createUser: inserting new user', { username, email, role });
const res = await pbPost('/api/collections/app_users/records', {
username,
password_hash,
role,
email,
email_verified: false,
verification_token,
verification_token_exp,
created: new Date().toISOString()
});
if (!res.ok) {
@@ -516,6 +551,23 @@ export async function createUser(username: string, password: string, role = 'use
return res.json() as Promise<User>;
}
/**
* Mark a user's email as verified and clear the verification token.
*/
export async function verifyUserEmail(userId: string): Promise<void> {
const res = await pbPatch(`/api/collections/app_users/records/${userId}`, {
email_verified: true,
verification_token: '',
verification_token_exp: ''
});
if (!res.ok) {
const body = await res.text().catch(() => '');
log.error('pocketbase', 'verifyUserEmail: PATCH failed', { userId, status: res.status, body });
throw new Error(`Failed to verify email: ${res.status}`);
}
log.info('pocketbase', 'verifyUserEmail: success', { userId });
}
/**
* Change a user's password. Verifies the current password first.
* Returns true on success, false if currentPassword is wrong.
@@ -556,6 +608,7 @@ export async function changePassword(
/**
* Verify username + password. Returns the user on success, null on failure.
* Throws with message 'Email not verified' if the account exists but hasn't been verified.
*/
export async function loginUser(username: string, password: string): Promise<User | null> {
log.debug('pocketbase', 'loginUser: lookup', { username });
@@ -569,6 +622,10 @@ export async function loginUser(username: string, password: string): Promise<Use
log.warn('pocketbase', 'loginUser: wrong password', { username });
return null;
}
if (!user.email_verified) {
log.warn('pocketbase', 'loginUser: email not verified', { username });
throw new Error('Email not verified');
}
log.info('pocketbase', 'loginUser: success', { username, role: user.role });
return user;
}