feat: implement QuestionBank CRUD with pagination and template query
- Add pagination support to findAll (page, limit query params) - Add findByTemplateId method to service - Add GET /by-template/:templateId endpoint to controller - Service already includes CRUD for QuestionBank and QuestionBankItem
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { UserRole } from '../user/user-role.enum';
|
||||
|
||||
@Injectable()
|
||||
export class AdminGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
|
||||
// Check if user exists and has admin privileges (Super Admin or Tenant Admin)
|
||||
return !!(
|
||||
user &&
|
||||
(user.role === UserRole.SUPER_ADMIN ||
|
||||
user.role === UserRole.TENANT_ADMIN ||
|
||||
user.isAdmin === true)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { Request } from 'express';
|
||||
import { IS_PUBLIC_KEY } from './public.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeyGuard implements CanActivate {
|
||||
constructor(
|
||||
private reflector: Reflector,
|
||||
private userService: UserService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context
|
||||
.switchToHttp()
|
||||
.getRequest<Request & { user?: any; tenantId?: string }>();
|
||||
const apiKey = this.extractApiKeyFromHeader(request);
|
||||
|
||||
if (apiKey) {
|
||||
const user = await this.userService.findByApiKey(apiKey);
|
||||
if (user) {
|
||||
request.user = user;
|
||||
request.tenantId = user.tenantId;
|
||||
return true;
|
||||
}
|
||||
throw new UnauthorizedException('Invalid API key');
|
||||
}
|
||||
|
||||
throw new UnauthorizedException('Missing API key');
|
||||
}
|
||||
|
||||
private extractApiKeyFromHeader(request: Request): string | undefined {
|
||||
const authHeader = request.headers.authorization;
|
||||
if (authHeader && authHeader.startsWith('Bearer kb_')) {
|
||||
return authHeader.substring(7, authHeader.length);
|
||||
}
|
||||
const headerKey = request.headers['x-api-key'] as string;
|
||||
if (headerKey) return headerKey;
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Controller, Get, Post, Request, UseGuards } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LocalAuthGuard } from './local-auth.guard';
|
||||
import { CombinedAuthGuard } from './combined-auth.guard';
|
||||
import { Public } from './public.decorator';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private authService: AuthService) {}
|
||||
|
||||
@Public()
|
||||
@UseGuards(LocalAuthGuard)
|
||||
@Post('login')
|
||||
async login(@Request() req) {
|
||||
return this.authService.login(req.user);
|
||||
}
|
||||
|
||||
@UseGuards(CombinedAuthGuard)
|
||||
@Get('profile')
|
||||
getProfile(@Request() req) {
|
||||
return req.user;
|
||||
}
|
||||
|
||||
@UseGuards(CombinedAuthGuard)
|
||||
@Get('api-key')
|
||||
async getApiKey(@Request() req) {
|
||||
const apiKey = await this.authService.getOrCreateApiKey(req.user.id);
|
||||
return { apiKey };
|
||||
}
|
||||
|
||||
@UseGuards(CombinedAuthGuard)
|
||||
@Post('api-key/regenerate')
|
||||
async regenerateApiKey(@Request() req) {
|
||||
const apiKey = await this.authService.regenerateApiKey(req.user.id);
|
||||
return { apiKey };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { LocalStrategy } from './local.strategy';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
import { TenantModule } from '../tenant/tenant.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
UserModule,
|
||||
TenantModule,
|
||||
PassportModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
secret: configService.get<string>('JWT_SECRET'),
|
||||
signOptions: { expiresIn: '1d' }, // Token expires in 1 day
|
||||
}),
|
||||
}),
|
||||
],
|
||||
providers: [AuthService, LocalStrategy, JwtStrategy],
|
||||
controllers: [AuthController],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { User } from '../user/user.entity';
|
||||
import { SafeUser } from '../user/dto/user-safe.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private userService: UserService,
|
||||
private jwtService: JwtService,
|
||||
) {}
|
||||
|
||||
async validateUser(username: string, pass: string): Promise<User | null> {
|
||||
const user = await this.userService.findOneByUsername(username);
|
||||
if (user && (await user.validatePassword(pass))) {
|
||||
return user;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async login(user: SafeUser) {
|
||||
const payload = {
|
||||
username: user.username,
|
||||
sub: user.id,
|
||||
role: user.role,
|
||||
tenantId: user.tenantId,
|
||||
};
|
||||
return {
|
||||
access_token: this.jwtService.sign(payload),
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
tenantId: user.tenantId,
|
||||
displayName: user.displayName,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getOrCreateApiKey(userId: string) {
|
||||
return this.userService.getOrCreateApiKey(userId);
|
||||
}
|
||||
|
||||
async regenerateApiKey(userId: string) {
|
||||
return this.userService.regenerateApiKey(userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { Request } from 'express';
|
||||
import { lastValueFrom, Observable } from 'rxjs';
|
||||
import { IS_PUBLIC_KEY } from './public.decorator';
|
||||
import { tenantStore } from '../tenant/tenant.store';
|
||||
import { UserRole } from '../user/user-role.enum';
|
||||
import { TenantService } from '../tenant/tenant.service';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* A combined authentication guard that accepts either:
|
||||
* 1. An API key via the `x-api-key` header (or `Authorization: Bearer kb_...`)
|
||||
* 2. A standard JWT Bearer token
|
||||
*
|
||||
* This replaces JwtAuthGuard on routes that should support both auth methods.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CombinedAuthGuard implements CanActivate {
|
||||
// We extend AuthGuard('jwt') functionality by composition
|
||||
private jwtGuard: ReturnType<typeof AuthGuard>;
|
||||
|
||||
constructor(
|
||||
private reflector: Reflector,
|
||||
private userService: UserService,
|
||||
private tenantService: TenantService,
|
||||
) {
|
||||
// Create a JWT guard instance
|
||||
const JwtGuardClass = AuthGuard('jwt');
|
||||
this.jwtGuard = new JwtGuardClass() as any;
|
||||
}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
// Allow @Public() decorated routes
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
const request = context
|
||||
.switchToHttp()
|
||||
.getRequest<Request & { user?: any; tenantId?: string }>();
|
||||
const logMsg = `\n[${new Date().toISOString()}] AuthGuard: ${request.method} ${request.url} (isPublic: ${isPublic})\n`;
|
||||
fs.appendFileSync('auth_debug.log', logMsg);
|
||||
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[CombinedAuthGuard] Checking auth for route: ${request.method} ${request.url}`,
|
||||
);
|
||||
|
||||
// --- Try API Key first ---
|
||||
const apiKey = this.extractApiKey(request);
|
||||
if (apiKey) {
|
||||
const user = await this.userService.findByApiKey(apiKey);
|
||||
if (user) {
|
||||
// If x-tenant-id is provided, verify membership
|
||||
const requestedTenantId = request.headers['x-tenant-id'] as string;
|
||||
let activeTenantId = user.tenantId;
|
||||
|
||||
if (requestedTenantId) {
|
||||
const memberships = await this.userService.getUserTenants(user.id);
|
||||
const hasAccess = memberships.some(
|
||||
(m) => m.tenantId === requestedTenantId,
|
||||
);
|
||||
|
||||
if (hasAccess || user.isAdmin) {
|
||||
activeTenantId = requestedTenantId;
|
||||
} else {
|
||||
throw new UnauthorizedException(
|
||||
'User does not belong to the requested tenant',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const role = await this.tenantService.getUserRole(
|
||||
user.id,
|
||||
activeTenantId,
|
||||
);
|
||||
|
||||
request.user = {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role,
|
||||
tenantId: activeTenantId,
|
||||
};
|
||||
request.tenantId = activeTenantId;
|
||||
|
||||
// Update tenant context store
|
||||
const store = tenantStore.getStore();
|
||||
if (store) {
|
||||
store.tenantId = activeTenantId;
|
||||
store.userId = user.id;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
throw new UnauthorizedException('Invalid API key');
|
||||
}
|
||||
|
||||
// --- Fall back to JWT ---
|
||||
try {
|
||||
const result = await (this.jwtGuard as any).canActivate(context);
|
||||
let hasJwtSession = false;
|
||||
|
||||
if (result instanceof Observable) {
|
||||
hasJwtSession = await lastValueFrom(result);
|
||||
} else {
|
||||
hasJwtSession = result;
|
||||
}
|
||||
|
||||
if (hasJwtSession) {
|
||||
const user = request.user;
|
||||
if (!user) return false;
|
||||
|
||||
const requestedTenantId = request.headers['x-tenant-id'] as string;
|
||||
|
||||
if (requestedTenantId && user.tenantId !== requestedTenantId) {
|
||||
const memberships = await this.userService.getUserTenants(user.id);
|
||||
const hasAccess = memberships.some(
|
||||
(m) => m.tenantId === requestedTenantId,
|
||||
);
|
||||
|
||||
if (hasAccess || user.isAdmin) {
|
||||
user.tenantId = requestedTenantId;
|
||||
} else {
|
||||
throw new UnauthorizedException(
|
||||
'User does not belong to the requested tenant',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch the role for the active tenant
|
||||
const role = await this.tenantService.getUserRole(
|
||||
user.id,
|
||||
user.tenantId,
|
||||
);
|
||||
user.role = role;
|
||||
|
||||
request.tenantId = user.tenantId;
|
||||
|
||||
// Update tenant context store
|
||||
const store = tenantStore.getStore();
|
||||
if (store) {
|
||||
store.tenantId = user.tenantId;
|
||||
store.userId = user.id;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
console.error(`[CombinedAuthGuard] JWT Auth Error:`, e);
|
||||
throw e instanceof UnauthorizedException
|
||||
? e
|
||||
: new UnauthorizedException('Authentication required');
|
||||
}
|
||||
}
|
||||
|
||||
private extractApiKey(request: Request): string | undefined {
|
||||
// Allow `Authorization: Bearer kb_...` form
|
||||
const authHeader = request.headers.authorization;
|
||||
if (authHeader?.startsWith('Bearer kb_')) {
|
||||
return authHeader.substring(7);
|
||||
}
|
||||
// Or a plain `x-api-key` header
|
||||
const headerKey = request.headers['x-api-key'] as string;
|
||||
if (headerKey) return headerKey;
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../user/user.entity';
|
||||
|
||||
@Entity('api_keys')
|
||||
export class ApiKey {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'user_id', type: 'uuid' })
|
||||
userId: string;
|
||||
|
||||
@ManyToOne(() => User, (user) => user.apiKeys, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user: User;
|
||||
|
||||
@Column({ type: 'text', unique: true })
|
||||
key: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { lastValueFrom, Observable } from 'rxjs';
|
||||
import { IS_PUBLIC_KEY } from './public.decorator';
|
||||
import { tenantStore } from '../tenant/tenant.store';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') implements CanActivate {
|
||||
constructor(private reflector: Reflector) {
|
||||
super();
|
||||
}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const result = await super.canActivate(context);
|
||||
let canActivate = false;
|
||||
|
||||
if (result instanceof Observable) {
|
||||
canActivate = await lastValueFrom(result);
|
||||
} else {
|
||||
canActivate = result;
|
||||
}
|
||||
|
||||
if (canActivate) {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
if (user) {
|
||||
const store = tenantStore.getStore();
|
||||
if (store) {
|
||||
store.tenantId = user.tenantId;
|
||||
store.userId = user.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
return canActivate;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { SafeUser } from '../user/dto/user-safe.dto';
|
||||
import { UserRole } from '../user/user-role.enum';
|
||||
import { TenantService } from '../tenant/tenant.service';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private userService: UserService,
|
||||
private tenantService: TenantService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: configService.get<string>('JWT_SECRET')!,
|
||||
});
|
||||
}
|
||||
|
||||
// Passport first verifies the JWT's signature and expiration, then calls this method.
|
||||
async validate(payload: {
|
||||
sub: string;
|
||||
username: string;
|
||||
role?: string;
|
||||
tenantId?: string;
|
||||
}): Promise<SafeUser | null> {
|
||||
// 1. ALWAYS lookup by ID (sub) for identity stability
|
||||
const user = await this.userService.findOneById(payload.sub);
|
||||
|
||||
if (user) {
|
||||
const { password, ...result } = user;
|
||||
|
||||
// In a multi-tenant setup, the tenantId in the payload is the "default" or "last active" one.
|
||||
// But it can be overridden by the x-tenant-id header in the guard.
|
||||
// Map the backend isAdmin flag to the global UserRole
|
||||
const activeTenantId = payload.tenantId || result.tenantId;
|
||||
|
||||
// Fetch the actual role for this tenant from the database
|
||||
const role = await this.tenantService.getUserRole(
|
||||
result.id,
|
||||
activeTenantId,
|
||||
);
|
||||
|
||||
return {
|
||||
...result,
|
||||
role,
|
||||
tenantId: activeTenantId,
|
||||
} as SafeUser;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class LocalAuthGuard extends AuthGuard('local') {}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Strategy } from 'passport-local';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { SafeUser } from '../user/dto/user-safe.dto'; // Import SafeUser
|
||||
import { I18nService } from '../i18n/i18n.service';
|
||||
import { UserRole } from '../user/user-role.enum';
|
||||
import { TenantService } from '../tenant/tenant.service';
|
||||
|
||||
@Injectable()
|
||||
export class LocalStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private i18nService: I18nService,
|
||||
private tenantService: TenantService,
|
||||
) {
|
||||
super({ usernameField: 'username' });
|
||||
}
|
||||
|
||||
async validate(username: string, password: string): Promise<SafeUser> {
|
||||
const user = await this.authService.validateUser(username, password);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException(
|
||||
this.i18nService.getMessage('incorrectCredentials'),
|
||||
);
|
||||
}
|
||||
const { password: userPassword, ...result } = user;
|
||||
|
||||
// Fetch the actual role for the user's primary tenant
|
||||
const role = await this.tenantService.getUserRole(user.id, user.tenantId);
|
||||
|
||||
return {
|
||||
...result,
|
||||
role,
|
||||
} as SafeUser;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { UserRole } from '../user/user-role.enum';
|
||||
|
||||
export const ROLES_KEY = 'roles';
|
||||
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { ROLES_KEY } from './roles.decorator';
|
||||
import { UserRole } from '../user/user-role.enum';
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(
|
||||
ROLES_KEY,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
|
||||
if (!requiredRoles) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { user } = context.switchToHttp().getRequest();
|
||||
// User might not be injected yet if auth guard fails, but auth guard runs first usually.
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return requiredRoles.includes(user.role);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { UserRole } from '../user/user-role.enum';
|
||||
|
||||
@Injectable()
|
||||
export class SuperAdminGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
return (
|
||||
user && (user.role === UserRole.SUPER_ADMIN || user.isAdmin === true)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { UserRole } from '../user/user-role.enum';
|
||||
|
||||
@Injectable()
|
||||
export class TenantAdminGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
return (
|
||||
user &&
|
||||
(user.role === UserRole.SUPER_ADMIN ||
|
||||
user.role === UserRole.TENANT_ADMIN ||
|
||||
user.isAdmin === true)
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user