feat: nav bar (Home link), profile management, TOTP 2FA

- Add Home/Profile links to nav bar alongside Vault/Settings/Logout
- Profile page: change email, display name, password
- TOTP 2FA: setup with QR code, verify, disable with password
- Login flow: two-step TOTP challenge when 2FA is enabled
- Backend: new endpoints PUT /profile, POST /totp/setup|verify|disable
- Migration: add totp_secret and totp_enabled columns to users

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-13 08:36:03 -04:00
parent f1e3892572
commit 13111ae007
11 changed files with 616 additions and 34 deletions

View File

@ -23,8 +23,10 @@
"class-validator": "^0.14.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.0",
"speakeasy": "^2.0.0",
"ssh2": "^1.15.0",
"uuid": "^13.0.0",
"ws": "^8.16.0"
@ -36,6 +38,8 @@
"@types/jest": "^30.0.0",
"@types/node": "^20.0.0",
"@types/passport-jwt": "^4.0.0",
"@types/qrcode": "^1.5.6",
"@types/speakeasy": "^2.0.10",
"@types/ssh2": "^1.15.0",
"@types/uuid": "^10.0.0",
"@types/ws": "^8.5.0",
@ -2460,6 +2464,16 @@
"@types/passport": "*"
}
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/qs": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
@ -2495,6 +2509,16 @@
"@types/node": "*"
}
},
"node_modules/@types/speakeasy": {
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/@types/speakeasy/-/speakeasy-2.0.10.tgz",
"integrity": "sha512-QVRlDW5r4yl7p7xkNIbAIC/JtyOcClDIIdKfuG7PWdDT1MmyhtXSANsildohy0K+Lmvf/9RUtLbNLMacvrVwxA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/ssh2": {
"version": "1.15.5",
"resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz",
@ -3143,6 +3167,12 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"node_modules/base32.js": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.0.1.tgz",
"integrity": "sha512-EGHIRiegFa62/SsA1J+Xs2tIzludPdzM064N9wjbiEgHnGnJ1V0WEpA4pEwCYT5nDvZk3ubf0shqaCS7k6xeUQ==",
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@ -3516,7 +3546,6 @@
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@ -4044,6 +4073,15 @@
"ms": "2.0.0"
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/dedent": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
@ -4188,6 +4226,12 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@ -4719,7 +4763,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
@ -4916,7 +4959,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
@ -6504,7 +6546,6 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
@ -7179,7 +7220,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
@ -7192,7 +7232,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
@ -7208,7 +7247,6 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@ -7302,7 +7340,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@ -7458,6 +7495,15 @@
"node": ">=4"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/pretty-format": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
@ -7566,6 +7612,75 @@
],
"license": "MIT"
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode/node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/qrcode/node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/qrcode/node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/qs": {
"version": "6.14.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
@ -7683,7 +7798,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@ -7699,6 +7813,12 @@
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resolve": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@ -8162,6 +8282,18 @@
"node": ">=0.10.0"
}
},
"node_modules/speakeasy": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/speakeasy/-/speakeasy-2.0.0.tgz",
"integrity": "sha512-lW2A2s5LKi8rwu77ewisuUOtlCydF/hmQSOJjpTqTj1gZLkNgTaYnyvfxy2WBr4T/h+9c4g8HIITfj83OkFQFw==",
"license": "MIT",
"dependencies": {
"base32.js": "0.0.1"
},
"engines": {
"node": ">= 0.10.0"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
@ -9234,6 +9366,12 @@
"node": ">= 8"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/wide-align": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
@ -9254,7 +9392,6 @@
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",

View File

@ -28,8 +28,10 @@
"class-validator": "^0.14.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.0",
"speakeasy": "^2.0.0",
"ssh2": "^1.15.0",
"uuid": "^13.0.0",
"ws": "^8.16.0"
@ -41,6 +43,8 @@
"@types/jest": "^30.0.0",
"@types/node": "^20.0.0",
"@types/passport-jwt": "^4.0.0",
"@types/qrcode": "^1.5.6",
"@types/speakeasy": "^2.0.10",
"@types/ssh2": "^1.15.0",
"@types/uuid": "^10.0.0",
"@types/ws": "^8.5.0",

View File

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "totp_secret" TEXT;
ALTER TABLE "users" ADD COLUMN "totp_enabled" BOOLEAN NOT NULL DEFAULT false;

View File

@ -12,6 +12,8 @@ model User {
email String @unique
passwordHash String @map("password_hash")
displayName String? @map("display_name")
totpSecret String? @map("totp_secret")
totpEnabled Boolean @default(false) @map("totp_enabled")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")

View File

@ -1,4 +1,4 @@
import { Controller, Post, Get, Body, Request, UseGuards } from '@nestjs/common';
import { Controller, Post, Get, Put, Body, Request, UseGuards } from '@nestjs/common';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './jwt-auth.guard';
import { LoginDto } from './dto/login.dto';
@ -9,7 +9,7 @@ export class AuthController {
@Post('login')
login(@Body() dto: LoginDto) {
return this.auth.login(dto.email, dto.password);
return this.auth.login(dto.email, dto.password, dto.totpCode);
}
@UseGuards(JwtAuthGuard)
@ -17,4 +17,28 @@ export class AuthController {
getProfile(@Request() req: any) {
return this.auth.getProfile(req.user.sub);
}
@UseGuards(JwtAuthGuard)
@Put('profile')
updateProfile(@Request() req: any, @Body() body: { email?: string; displayName?: string; currentPassword?: string; newPassword?: string }) {
return this.auth.updateProfile(req.user.sub, body);
}
@UseGuards(JwtAuthGuard)
@Post('totp/setup')
totpSetup(@Request() req: any) {
return this.auth.totpSetup(req.user.sub);
}
@UseGuards(JwtAuthGuard)
@Post('totp/verify')
totpVerify(@Request() req: any, @Body() body: { code: string }) {
return this.auth.totpVerify(req.user.sub, body.code);
}
@UseGuards(JwtAuthGuard)
@Post('totp/disable')
totpDisable(@Request() req: any, @Body() body: { password: string }) {
return this.auth.totpDisable(req.user.sub, body.password);
}
}

View File

@ -1,7 +1,9 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { Injectable, UnauthorizedException, BadRequestException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { PrismaService } from '../prisma/prisma.service';
import * as bcrypt from 'bcrypt';
import * as speakeasy from 'speakeasy';
import * as QRCode from 'qrcode';
@Injectable()
export class AuthService {
@ -10,13 +12,28 @@ export class AuthService {
private jwt: JwtService,
) {}
async login(email: string, password: string) {
async login(email: string, password: string, totpCode?: string) {
const user = await this.prisma.user.findUnique({ where: { email } });
if (!user) throw new UnauthorizedException('Invalid credentials');
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) throw new UnauthorizedException('Invalid credentials');
// If TOTP is enabled, require a valid code
if (user.totpEnabled && user.totpSecret) {
if (!totpCode) {
// Signal frontend to show TOTP input
return { requires_totp: true };
}
const verified = speakeasy.totp.verify({
secret: user.totpSecret,
encoding: 'base32',
token: totpCode,
window: 1,
});
if (!verified) throw new UnauthorizedException('Invalid TOTP code');
}
const payload = { sub: user.id, email: user.email };
return {
access_token: this.jwt.sign(payload),
@ -27,6 +44,100 @@ export class AuthService {
async getProfile(userId: number) {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new UnauthorizedException();
return { id: user.id, email: user.email, displayName: user.displayName };
return {
id: user.id,
email: user.email,
displayName: user.displayName,
totpEnabled: user.totpEnabled,
};
}
async updateProfile(userId: number, data: { email?: string; displayName?: string; currentPassword?: string; newPassword?: string }) {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new UnauthorizedException();
const update: any = {};
if (data.email && data.email !== user.email) {
update.email = data.email;
}
if (data.displayName !== undefined) {
update.displayName = data.displayName;
}
if (data.newPassword) {
if (!data.currentPassword) {
throw new BadRequestException('Current password required to set new password');
}
const valid = await bcrypt.compare(data.currentPassword, user.passwordHash);
if (!valid) throw new BadRequestException('Current password is incorrect');
update.passwordHash = await bcrypt.hash(data.newPassword, 10);
}
if (Object.keys(update).length === 0) {
return { message: 'No changes' };
}
await this.prisma.user.update({ where: { id: userId }, data: update });
return { message: 'Profile updated' };
}
async totpSetup(userId: number) {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new UnauthorizedException();
if (user.totpEnabled) throw new BadRequestException('TOTP already enabled');
const secret = speakeasy.generateSecret({
name: `Wraith (${user.email})`,
issuer: 'Wraith',
});
// Store secret temporarily (not enabled until verified)
await this.prisma.user.update({
where: { id: userId },
data: { totpSecret: secret.base32 },
});
const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url!);
return {
secret: secret.base32,
qrCode: qrCodeUrl,
};
}
async totpVerify(userId: number, code: string) {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user || !user.totpSecret) throw new BadRequestException('TOTP not set up');
const verified = speakeasy.totp.verify({
secret: user.totpSecret,
encoding: 'base32',
token: code,
window: 1,
});
if (!verified) throw new BadRequestException('Invalid code — try again');
await this.prisma.user.update({
where: { id: userId },
data: { totpEnabled: true },
});
return { message: 'TOTP enabled successfully' };
}
async totpDisable(userId: number, password: string) {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new UnauthorizedException();
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) throw new BadRequestException('Password incorrect');
await this.prisma.user.update({
where: { id: userId },
data: { totpEnabled: false, totpSecret: null },
});
return { message: 'TOTP disabled' };
}
}

View File

@ -1,4 +1,4 @@
import { IsEmail, IsString, MinLength } from 'class-validator';
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';
export class LoginDto {
@IsEmail()
@ -7,4 +7,8 @@ export class LoginDto {
@IsString()
@MinLength(1)
password: string;
@IsOptional()
@IsString()
totpCode?: string;
}

View File

@ -36,7 +36,9 @@ onMounted(async () => {
<h1 class="text-lg font-bold tracking-wider text-wraith-400">WRAITH</h1>
</div>
<div class="flex items-center gap-3">
<NuxtLink to="/" class="text-sm text-gray-400 hover:text-white">Home</NuxtLink>
<NuxtLink to="/vault" class="text-sm text-gray-400 hover:text-white">Vault</NuxtLink>
<NuxtLink to="/profile" class="text-sm text-gray-400 hover:text-white">Profile</NuxtLink>
<NuxtLink to="/settings" class="text-sm text-gray-400 hover:text-white">Settings</NuxtLink>
<button @click="auth.logout()" class="text-sm text-gray-500 hover:text-red-400">Logout</button>
</div>

View File

@ -4,14 +4,25 @@ definePageMeta({ layout: 'auth' })
const auth = useAuthStore()
const email = ref('admin@wraith.local')
const password = ref('')
const totpCode = ref('')
const error = ref('')
const loading = ref(false)
const requiresTotp = ref(false)
async function handleLogin() {
error.value = ''
loading.value = true
try {
await auth.login(email.value, password.value)
const res = await auth.login(
email.value,
password.value,
requiresTotp.value ? totpCode.value : undefined,
)
if (res.requires_totp) {
requiresTotp.value = true
loading.value = false
return
}
navigateTo('/')
} catch (e: any) {
error.value = e.data?.message || 'Login failed'
@ -28,20 +39,37 @@ async function handleLogin() {
<p class="text-gray-500 mt-1 text-sm">Remote Access Terminal</p>
</div>
<form @submit.prevent="handleLogin" class="space-y-4 bg-gray-900 p-6 rounded-lg border border-gray-800">
<div>
<label class="block text-sm text-gray-400 mb-1">Email</label>
<input v-model="email" type="email" required autofocus
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:border-wraith-500 focus:outline-none" />
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">Password</label>
<input v-model="password" type="password" required
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:border-wraith-500 focus:outline-none" />
</div>
<template v-if="!requiresTotp">
<div>
<label class="block text-sm text-gray-400 mb-1">Email</label>
<input v-model="email" type="email" required autofocus
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:border-wraith-500 focus:outline-none" />
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">Password</label>
<input v-model="password" type="password" required
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:border-wraith-500 focus:outline-none" />
</div>
</template>
<template v-else>
<div class="text-center mb-2">
<p class="text-sm text-gray-400">Enter your authenticator code</p>
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">TOTP Code</label>
<input v-model="totpCode" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="6"
required autofocus autocomplete="one-time-code" placeholder="000000"
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white text-center text-lg tracking-widest focus:border-wraith-500 focus:outline-none" />
</div>
</template>
<p v-if="error" class="text-red-400 text-sm">{{ error }}</p>
<button type="submit" :disabled="loading"
class="w-full py-2 bg-wraith-600 hover:bg-wraith-700 text-white rounded font-medium disabled:opacity-50">
{{ loading ? 'Signing in...' : 'Sign In' }}
{{ loading ? 'Verifying...' : requiresTotp ? 'Verify' : 'Sign In' }}
</button>
<button v-if="requiresTotp" type="button" @click="requiresTotp = false; totpCode = ''; error = ''"
class="w-full py-2 text-sm text-gray-500 hover:text-gray-300">
Back to login
</button>
</form>
</div>

251
frontend/pages/profile.vue Normal file
View File

@ -0,0 +1,251 @@
<script setup lang="ts">
const auth = useAuthStore()
const email = ref('')
const displayName = ref('')
const currentPassword = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
const profileMsg = ref('')
const profileError = ref('')
const profileLoading = ref(false)
// TOTP state
const totpEnabled = ref(false)
const totpSetupData = ref<{ secret: string; qrCode: string } | null>(null)
const totpCode = ref('')
const totpDisablePassword = ref('')
const totpMsg = ref('')
const totpError = ref('')
const totpLoading = ref(false)
function headers() {
return { Authorization: `Bearer ${auth.token}` }
}
onMounted(async () => {
try {
const profile = await $fetch<{ id: number; email: string; displayName: string | null; totpEnabled: boolean }>('/api/auth/profile', {
headers: headers(),
})
email.value = profile.email
displayName.value = profile.displayName || ''
totpEnabled.value = profile.totpEnabled
} catch {
// handled by layout auth guard
}
})
async function saveProfile() {
profileMsg.value = ''
profileError.value = ''
if (newPassword.value && newPassword.value !== confirmPassword.value) {
profileError.value = 'New passwords do not match'
return
}
profileLoading.value = true
try {
const body: Record<string, string> = {}
if (email.value) body.email = email.value
if (displayName.value !== undefined) body.displayName = displayName.value
if (newPassword.value) {
body.currentPassword = currentPassword.value
body.newPassword = newPassword.value
}
const res = await $fetch<{ message: string }>('/api/auth/profile', {
method: 'PUT',
body,
headers: headers(),
})
profileMsg.value = res.message
currentPassword.value = ''
newPassword.value = ''
confirmPassword.value = ''
// Refresh profile in auth store
await auth.fetchProfile()
} catch (e: any) {
profileError.value = e.data?.message || 'Failed to update profile'
} finally {
profileLoading.value = false
}
}
async function setupTotp() {
totpError.value = ''
totpMsg.value = ''
totpLoading.value = true
try {
totpSetupData.value = await $fetch('/api/auth/totp/setup', {
method: 'POST',
headers: headers(),
})
} catch (e: any) {
totpError.value = e.data?.message || 'Failed to start TOTP setup'
} finally {
totpLoading.value = false
}
}
async function verifyTotp() {
totpError.value = ''
totpMsg.value = ''
totpLoading.value = true
try {
const res = await $fetch<{ message: string }>('/api/auth/totp/verify', {
method: 'POST',
body: { code: totpCode.value },
headers: headers(),
})
totpMsg.value = res.message
totpEnabled.value = true
totpSetupData.value = null
totpCode.value = ''
} catch (e: any) {
totpError.value = e.data?.message || 'Verification failed'
} finally {
totpLoading.value = false
}
}
async function disableTotp() {
totpError.value = ''
totpMsg.value = ''
totpLoading.value = true
try {
const res = await $fetch<{ message: string }>('/api/auth/totp/disable', {
method: 'POST',
body: { password: totpDisablePassword.value },
headers: headers(),
})
totpMsg.value = res.message
totpEnabled.value = false
totpDisablePassword.value = ''
} catch (e: any) {
totpError.value = e.data?.message || 'Failed to disable TOTP'
} finally {
totpLoading.value = false
}
}
</script>
<template>
<div class="max-w-2xl mx-auto p-6 space-y-8">
<h2 class="text-xl font-bold text-white">Profile</h2>
<!-- Profile form -->
<form @submit.prevent="saveProfile" class="space-y-4 bg-gray-900 p-6 rounded-lg border border-gray-800">
<h3 class="text-sm font-semibold text-gray-400 uppercase tracking-wider">Account Details</h3>
<div>
<label class="block text-sm text-gray-400 mb-1">Email</label>
<input v-model="email" type="email"
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:border-wraith-500 focus:outline-none" />
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">Display Name</label>
<input v-model="displayName" type="text"
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:border-wraith-500 focus:outline-none" />
</div>
<hr class="border-gray-800" />
<h3 class="text-sm font-semibold text-gray-400 uppercase tracking-wider">Change Password</h3>
<div>
<label class="block text-sm text-gray-400 mb-1">Current Password</label>
<input v-model="currentPassword" type="password" autocomplete="current-password"
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:border-wraith-500 focus:outline-none" />
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">New Password</label>
<input v-model="newPassword" type="password" autocomplete="new-password"
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:border-wraith-500 focus:outline-none" />
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">Confirm New Password</label>
<input v-model="confirmPassword" type="password" autocomplete="new-password"
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:border-wraith-500 focus:outline-none" />
</div>
<p v-if="profileMsg" class="text-green-400 text-sm">{{ profileMsg }}</p>
<p v-if="profileError" class="text-red-400 text-sm">{{ profileError }}</p>
<button type="submit" :disabled="profileLoading"
class="px-4 py-2 bg-wraith-600 hover:bg-wraith-700 text-white rounded disabled:opacity-50">
{{ profileLoading ? 'Saving...' : 'Save Changes' }}
</button>
</form>
<!-- TOTP Section -->
<div class="bg-gray-900 p-6 rounded-lg border border-gray-800 space-y-4">
<h3 class="text-sm font-semibold text-gray-400 uppercase tracking-wider">Two-Factor Authentication</h3>
<!-- TOTP is enabled -->
<template v-if="totpEnabled && !totpSetupData">
<div class="flex items-center gap-3">
<span class="w-2 h-2 rounded-full bg-green-500" />
<span class="text-sm text-green-400">TOTP is enabled</span>
</div>
<div class="space-y-3">
<div>
<label class="block text-sm text-gray-400 mb-1">Password (to disable)</label>
<input v-model="totpDisablePassword" type="password"
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:border-wraith-500 focus:outline-none" />
</div>
<button @click="disableTotp" :disabled="totpLoading || !totpDisablePassword"
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded text-sm disabled:opacity-50">
{{ totpLoading ? 'Disabling...' : 'Disable TOTP' }}
</button>
</div>
</template>
<!-- TOTP setup in progress -->
<template v-else-if="totpSetupData">
<p class="text-sm text-gray-400">Scan this QR code with your authenticator app, then enter the code below to verify.</p>
<div class="flex justify-center">
<img :src="totpSetupData.qrCode" alt="TOTP QR Code" class="w-48 h-48 rounded" />
</div>
<div class="text-center">
<p class="text-xs text-gray-500 mb-1">Manual key:</p>
<code class="text-xs text-wraith-400 select-all">{{ totpSetupData.secret }}</code>
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">Verification Code</label>
<input v-model="totpCode" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="6"
placeholder="000000" autocomplete="one-time-code"
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white text-center text-lg tracking-widest focus:border-wraith-500 focus:outline-none" />
</div>
<div class="flex gap-2">
<button @click="verifyTotp" :disabled="totpLoading || totpCode.length < 6"
class="px-4 py-2 bg-wraith-600 hover:bg-wraith-700 text-white rounded text-sm disabled:opacity-50">
{{ totpLoading ? 'Verifying...' : 'Verify & Enable' }}
</button>
<button @click="totpSetupData = null; totpCode = ''"
class="px-4 py-2 text-sm text-gray-500 hover:text-gray-300">
Cancel
</button>
</div>
</template>
<!-- TOTP not enabled -->
<template v-else>
<div class="flex items-center gap-3">
<span class="w-2 h-2 rounded-full bg-gray-600" />
<span class="text-sm text-gray-500">TOTP is not enabled</span>
</div>
<button @click="setupTotp" :disabled="totpLoading"
class="px-4 py-2 bg-wraith-600 hover:bg-wraith-700 text-white rounded text-sm disabled:opacity-50">
{{ totpLoading ? 'Setting up...' : 'Enable TOTP' }}
</button>
</template>
<p v-if="totpMsg" class="text-green-400 text-sm">{{ totpMsg }}</p>
<p v-if="totpError" class="text-red-400 text-sm">{{ totpError }}</p>
</div>
</div>
</template>

View File

@ -4,6 +4,13 @@ interface User {
id: number
email: string
displayName: string | null
totpEnabled?: boolean
}
interface LoginResponse {
access_token?: string
user?: User
requires_totp?: boolean
}
export const useAuthStore = defineStore('auth', {
@ -15,14 +22,23 @@ export const useAuthStore = defineStore('auth', {
isAuthenticated: (state) => !!state.token,
},
actions: {
async login(email: string, password: string) {
const res = await $fetch<{ access_token: string; user: User }>('/api/auth/login', {
async login(email: string, password: string, totpCode?: string): Promise<LoginResponse> {
const body: Record<string, string> = { email, password }
if (totpCode) body.totpCode = totpCode
const res = await $fetch<LoginResponse>('/api/auth/login', {
method: 'POST',
body: { email, password },
body,
})
this.token = res.access_token
this.user = res.user
localStorage.setItem('wraith_token', res.access_token)
if (res.requires_totp) {
return { requires_totp: true }
}
this.token = res.access_token!
this.user = res.user!
localStorage.setItem('wraith_token', res.access_token!)
return res
},
logout() {
this.token = ''