Compare commits

...

10 commits

Author SHA1 Message Date
thibaud-leclere
b1e4b648ee feat: add local auth settings
Some checks failed
Node.js CI / Test (push) Has been cancelled
2026-05-06 08:52:10 +02:00
thibaud-leclere
d4bbde7deb feat: add local user creation 2026-05-06 08:50:30 +02:00
thibaud-leclere
892f69817b feat: add local app login 2026-05-06 08:47:37 +02:00
thibaud-leclere
b17375eb16 feat: add local admin login 2026-05-06 08:44:14 +02:00
thibaud-leclere
4c30592ae4 feat: add local auth onboarding 2026-05-06 08:41:02 +02:00
thibaud-leclere
60cf156230 feat: expose local auth endpoints 2026-05-06 08:31:39 +02:00
thibaud-leclere
0ec0ae442a feat: add local auth service 2026-05-06 08:27:26 +02:00
thibaud-leclere
c8b7a172a4 feat: allow local auth provider 2026-05-06 08:21:24 +02:00
thibaud-leclere
7b4cfb4103 feat: add local auth data model 2026-05-06 08:17:05 +02:00
thibaud-leclere
4b7c61d180 chore: ignore superpowers docs 2026-05-05 17:53:16 +02:00
33 changed files with 1536 additions and 86 deletions

1
.gitignore vendored
View file

@ -188,3 +188,4 @@ devenv.local.nix
.cursor/
.aider*
.windsurfrules
docs/superpowers/

View file

@ -0,0 +1,21 @@
-- Add local username/password authentication support.
ALTER TABLE "User" ADD COLUMN "username" TEXT;
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
CREATE TABLE "LocalCredential" (
"id" TEXT NOT NULL,
"userUid" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"createdOn" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedOn" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "LocalCredential_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "LocalCredential_userUid_key" ON "LocalCredential"("userUid");
ALTER TABLE "LocalCredential"
ADD CONSTRAINT "LocalCredential_userUid_fkey"
FOREIGN KEY ("userUid") REFERENCES "User"("uid")
ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -94,6 +94,7 @@ model TeamEnvironment {
model User {
uid String @id @default(cuid())
username String? @unique
displayName String?
email String? @unique
photoURL String?
@ -104,6 +105,7 @@ model User {
createdOn DateTime @default(now()) @db.Timestamptz(3)
lastLoggedOn DateTime? @db.Timestamptz(3)
lastActiveOn DateTime? @db.Timestamptz(3)
localCredential LocalCredential?
providerAccounts Account[]
invitedUsers InvitedUsers[]
mockServers MockServer[]
@ -117,6 +119,15 @@ model User {
VerificationToken VerificationToken[]
}
model LocalCredential {
id String @id @default(cuid())
userUid String @unique
passwordHash String
createdOn DateTime @default(now()) @db.Timestamptz(3)
updatedOn DateTime @updatedAt @db.Timestamptz(3)
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
}
model Account {
id String @id @default(cuid())
userId String

View file

@ -0,0 +1,144 @@
import { ConfigService } from '@nestjs/config';
import { mockDeep, mockReset } from 'jest-mock-extended';
import * as E from 'fp-ts/Either';
import { Response } from 'express';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { LocalAuthService } from './local-auth.service';
import { AuthUser } from 'src/types/AuthUser';
const mockAuthService = mockDeep<AuthService>();
const mockConfigService = mockDeep<ConfigService>();
const mockLocalAuthService = mockDeep<LocalAuthService>();
const authController = new AuthController(
mockAuthService,
mockConfigService,
mockLocalAuthService,
);
const currentTime = new Date();
const adminUser: AuthUser = {
uid: 'admin-1',
username: 'admin',
email: null,
displayName: 'Admin',
photoURL: null,
isAdmin: true,
refreshToken: null,
lastLoggedOn: currentTime,
lastActiveOn: currentTime,
createdOn: currentTime,
currentGQLSession: null,
currentRESTSession: null,
};
function createMockResponse() {
const res = {
cookie: jest.fn(),
status: jest.fn().mockReturnThis(),
send: jest.fn(),
};
return res as unknown as Response & typeof res;
}
describe('AuthController local auth', () => {
beforeEach(() => {
mockReset(mockAuthService);
mockReset(mockConfigService);
mockReset(mockLocalAuthService);
mockConfigService.get.mockImplementation((key: string) => {
if (key === 'INFRA.ACCESS_TOKEN_VALIDITY') return '86400000';
if (key === 'INFRA.REFRESH_TOKEN_VALIDITY') return '604800000';
if (key === 'INFRA.ALLOW_SECURE_COOKIES') return 'false';
return null;
});
});
it('sets auth cookies after local signin succeeds', async () => {
const res = createMockResponse();
mockLocalAuthService.signIn.mockResolvedValue(
E.right({
access_token: 'access-token',
refresh_token: 'refresh-token',
}),
);
await authController.signInLocal(
{
username: 'admin',
password: 'strong-password',
},
res,
);
expect(mockLocalAuthService.signIn).toHaveBeenCalledWith({
username: 'admin',
password: 'strong-password',
});
expect(res.cookie).toHaveBeenCalledTimes(2);
expect(res.status).toHaveBeenCalledWith(200);
});
it('sets auth cookies after local setup admin succeeds', async () => {
const res = createMockResponse();
mockLocalAuthService.setupFirstAdmin.mockResolvedValue(
E.right({
access_token: 'access-token',
refresh_token: 'refresh-token',
}),
);
await authController.setupLocalAdmin(
{
username: 'admin',
password: 'strong-password',
},
res,
);
expect(mockLocalAuthService.setupFirstAdmin).toHaveBeenCalledWith({
username: 'admin',
password: 'strong-password',
});
expect(res.cookie).toHaveBeenCalledTimes(2);
expect(res.status).toHaveBeenCalledWith(200);
});
it('delegates local user creation to the local auth service', async () => {
mockLocalAuthService.createLocalUser.mockResolvedValue(
E.right({
uid: 'user-1',
username: 'dwight',
displayName: 'Dwight Schrute',
email: null,
photoURL: null,
isAdmin: false,
}),
);
const result = await authController.createLocalUser(adminUser, {
username: 'dwight',
password: 'strong-password',
displayName: 'Dwight Schrute',
});
expect(mockLocalAuthService.createLocalUser).toHaveBeenCalledWith(
{
username: 'dwight',
password: 'strong-password',
displayName: 'Dwight Schrute',
},
adminUser,
);
expect(result).toEqual({
uid: 'user-1',
username: 'dwight',
displayName: 'Dwight Schrute',
email: null,
photoURL: null,
isAdmin: false,
});
});
});

View file

@ -30,6 +30,12 @@ import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
import { ConfigService } from '@nestjs/config';
import { throwHTTPErr } from 'src/utils';
import { UserLastLoginInterceptor } from 'src/interceptors/user-last-login.interceptor';
import {
CreateLocalUserDto,
LocalSetupAdminDto,
LocalSignInDto,
} from './dto/local-auth.dto';
import { LocalAuthService } from './local-auth.service';
@UseGuards(ThrottlerBehindProxyGuard)
@Controller({ path: 'auth', version: '1' })
@ -37,6 +43,7 @@ export class AuthController {
constructor(
private authService: AuthService,
private configService: ConfigService,
private localAuthService: LocalAuthService,
) {}
@Get('providers')
@ -80,6 +87,34 @@ export class AuthController {
authCookieHandler(res, authTokens.right, false, null, this.configService);
}
@Post('local/signin')
async signInLocal(@Body() data: LocalSignInDto, @Res() res: Response) {
const authTokens = await this.localAuthService.signIn(data);
if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left);
authCookieHandler(res, authTokens.right, false, null, this.configService);
}
@Post('local/setup-admin')
async setupLocalAdmin(
@Body() data: LocalSetupAdminDto,
@Res() res: Response,
) {
const authTokens = await this.localAuthService.setupFirstAdmin(data);
if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left);
authCookieHandler(res, authTokens.right, false, null, this.configService);
}
@Post('local/users')
@UseGuards(JwtAuthGuard)
async createLocalUser(
@GqlUser() user: AuthUser,
@Body() data: CreateLocalUserDto,
) {
const createdUser = await this.localAuthService.createLocalUser(data, user);
if (E.isLeft(createdUser)) throwHTTPErr(createdUser.left);
return createdUser.right;
}
/**
** Route to refresh auth tokens with Refresh Token Rotation
* @see https://auth0.com/docs/secure/tokens/refresh-tokens/refresh-token-rotation

View file

@ -16,6 +16,7 @@ import {
isInfraConfigTablePopulated,
} from 'src/infra-config/helper';
import { InfraConfigModule } from 'src/infra-config/infra-config.module';
import { LocalAuthService } from './local-auth.service';
@Module({
imports: [
@ -29,7 +30,7 @@ import { InfraConfigModule } from 'src/infra-config/infra-config.module';
}),
InfraConfigModule,
],
providers: [AuthService],
providers: [AuthService, LocalAuthService],
controllers: [AuthController],
})
export class AuthModule {

View file

@ -0,0 +1,29 @@
import {
IsOptional,
IsString,
Matches,
MaxLength,
MinLength,
} from 'class-validator';
export class LocalSignInDto {
@IsString()
@MinLength(3)
@MaxLength(40)
@Matches(/^[a-zA-Z0-9_.-]+$/)
username: string;
@IsString()
@MinLength(12)
@MaxLength(256)
password: string;
}
export class LocalSetupAdminDto extends LocalSignInDto {}
export class CreateLocalUserDto extends LocalSignInDto {
@IsOptional()
@IsString()
@MaxLength(80)
displayName?: string;
}

View file

@ -27,6 +27,7 @@ export enum AuthProvider {
GITHUB = 'GITHUB',
MICROSOFT = 'MICROSOFT',
EMAIL = 'EMAIL',
LOCAL = 'LOCAL',
}
/**

View file

@ -0,0 +1,318 @@
import { HttpStatus } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { mockDeep, mockReset } from 'jest-mock-extended';
import * as argon2 from 'argon2';
import * as E from 'fp-ts/Either';
import {
AUTH_ADMIN_REQUIRED,
AUTH_INVALID_LOCAL_CREDENTIALS,
AUTH_LOCAL_SETUP_NOT_ALLOWED,
AUTH_USERNAME_ALREADY_EXISTS,
} from 'src/errors';
import { PrismaService } from 'src/prisma/prisma.service';
import { InfraConfigEnum } from 'src/types/InfraConfig';
import { AuthUser } from 'src/types/AuthUser';
import { AuthTokens } from 'src/types/AuthTokens';
import { AuthService } from './auth.service';
import { LocalAuthService } from './local-auth.service';
jest.mock('argon2', () => ({
hash: jest.fn(),
verify: jest.fn(),
}));
const mockPrisma = mockDeep<PrismaService>();
const mockAuthService = mockDeep<AuthService>();
const mockConfigService = mockDeep<ConfigService>();
const localAuthService = new LocalAuthService(
mockPrisma,
mockAuthService,
mockConfigService,
);
const currentTime = new Date();
const user: AuthUser = {
uid: 'user-1',
username: 'dwight',
email: null,
displayName: 'Dwight Schrute',
photoURL: null,
isAdmin: false,
refreshToken: null,
lastLoggedOn: currentTime,
lastActiveOn: currentTime,
createdOn: currentTime,
currentGQLSession: null,
currentRESTSession: null,
};
const adminUser: AuthUser = {
...user,
uid: 'admin-1',
username: 'admin',
isAdmin: true,
};
const tokens: AuthTokens = {
access_token: 'access-token',
refresh_token: 'refresh-token',
};
describe('LocalAuthService', () => {
beforeEach(() => {
mockReset(mockPrisma);
mockReset(mockAuthService);
mockReset(mockConfigService);
jest.mocked(argon2.hash).mockReset();
jest.mocked(argon2.verify).mockReset();
mockConfigService.get.mockReturnValue('LOCAL');
mockAuthService.generateAuthTokens.mockResolvedValue(E.right(tokens));
jest.mocked(argon2.hash).mockResolvedValue('hashed-password');
});
it('creates the first local admin before onboarding is completed', async () => {
mockPrisma.infraConfig.findUnique.mockResolvedValue({
id: 'onboarding',
name: InfraConfigEnum.ONBOARDING_COMPLETED,
value: 'false',
lastSyncedEnvFileValue: null,
isEncrypted: false,
createdOn: currentTime,
updatedOn: currentTime,
});
mockPrisma.user.findFirst.mockResolvedValue(null);
mockPrisma.user.create.mockResolvedValue(adminUser);
const result = await localAuthService.setupFirstAdmin({
username: ' Dwight ',
password: 'strong-password',
});
expect(mockPrisma.user.create).toHaveBeenCalledWith({
data: {
username: 'dwight',
displayName: 'dwight',
isAdmin: true,
localCredential: {
create: {
passwordHash: 'hashed-password',
},
},
providerAccounts: {
create: {
provider: 'LOCAL',
providerAccountId: 'dwight',
},
},
},
});
expect(result).toEqualRight(tokens);
});
it('reuses an existing first local admin before onboarding is completed', async () => {
mockPrisma.infraConfig.findUnique.mockResolvedValue({
id: 'onboarding',
name: InfraConfigEnum.ONBOARDING_COMPLETED,
value: 'false',
lastSyncedEnvFileValue: null,
isEncrypted: false,
createdOn: currentTime,
updatedOn: currentTime,
});
mockPrisma.user.findFirst.mockResolvedValue({
...adminUser,
localCredential: {
id: 'credential-1',
userUid: adminUser.uid,
passwordHash: 'hashed-password',
createdOn: currentTime,
updatedOn: currentTime,
},
} as any);
jest.mocked(argon2.verify).mockResolvedValue(true);
const result = await localAuthService.setupFirstAdmin({
username: 'Admin',
password: 'strong-password',
});
expect(mockPrisma.user.create).not.toHaveBeenCalled();
expect(mockAuthService.generateAuthTokens).toHaveBeenCalledWith(
adminUser.uid,
);
expect(result).toEqualRight(tokens);
});
it('refuses setup admin after onboarding is completed', async () => {
mockPrisma.infraConfig.findUnique.mockResolvedValue({
id: 'onboarding',
name: InfraConfigEnum.ONBOARDING_COMPLETED,
value: 'true',
lastSyncedEnvFileValue: null,
isEncrypted: false,
createdOn: currentTime,
updatedOn: currentTime,
});
const result = await localAuthService.setupFirstAdmin({
username: 'dwight',
password: 'strong-password',
});
expect(result).toEqualLeft({
message: AUTH_LOCAL_SETUP_NOT_ALLOWED,
statusCode: HttpStatus.FORBIDDEN,
});
expect(mockPrisma.user.create).not.toHaveBeenCalled();
});
it('signs in a local user with valid credentials', async () => {
mockPrisma.user.findFirst.mockResolvedValue({
...user,
localCredential: {
id: 'credential-1',
userUid: user.uid,
passwordHash: 'hashed-password',
createdOn: currentTime,
updatedOn: currentTime,
},
} as any);
jest.mocked(argon2.verify).mockResolvedValue(true);
const result = await localAuthService.signIn({
username: 'Dwight',
password: 'strong-password',
});
expect(mockPrisma.user.findFirst).toHaveBeenCalledWith({
where: {
username: {
equals: 'dwight',
mode: 'insensitive',
},
},
include: {
localCredential: true,
},
});
expect(result).toEqualRight(tokens);
});
it('returns generic invalid credentials for an unknown username', async () => {
mockPrisma.user.findFirst.mockResolvedValue(null);
const result = await localAuthService.signIn({
username: 'unknown',
password: 'strong-password',
});
expect(result).toEqualLeft({
message: AUTH_INVALID_LOCAL_CREDENTIALS,
statusCode: HttpStatus.UNAUTHORIZED,
});
});
it('returns generic invalid credentials for a bad password', async () => {
mockPrisma.user.findFirst.mockResolvedValue({
...user,
localCredential: {
id: 'credential-1',
userUid: user.uid,
passwordHash: 'hashed-password',
createdOn: currentTime,
updatedOn: currentTime,
},
} as any);
jest.mocked(argon2.verify).mockResolvedValue(false);
const result = await localAuthService.signIn({
username: 'dwight',
password: 'wrong-password',
});
expect(result).toEqualLeft({
message: AUTH_INVALID_LOCAL_CREDENTIALS,
statusCode: HttpStatus.UNAUTHORIZED,
});
});
it('refuses duplicate username case-insensitively', async () => {
mockPrisma.user.findFirst.mockResolvedValue(user);
const result = await localAuthService.createLocalUser(
{
username: 'Dwight',
password: 'strong-password',
displayName: 'Dwight Schrute',
},
adminUser,
);
expect(result).toEqualLeft({
message: AUTH_USERNAME_ALREADY_EXISTS,
statusCode: HttpStatus.CONFLICT,
});
expect(mockPrisma.user.create).not.toHaveBeenCalled();
});
it('creates a non-admin local user when requested by an admin', async () => {
mockPrisma.user.findFirst.mockResolvedValue(null);
mockPrisma.user.create.mockResolvedValue(user);
const result = await localAuthService.createLocalUser(
{
username: 'Dwight',
password: 'strong-password',
displayName: 'Dwight Schrute',
},
adminUser,
);
expect(mockPrisma.user.create).toHaveBeenCalledWith({
data: {
username: 'dwight',
displayName: 'Dwight Schrute',
isAdmin: false,
localCredential: {
create: {
passwordHash: 'hashed-password',
},
},
providerAccounts: {
create: {
provider: 'LOCAL',
providerAccountId: 'dwight',
},
},
},
});
expect(result).toEqualRight({
uid: user.uid,
username: user.username,
displayName: user.displayName,
email: user.email,
photoURL: user.photoURL,
isAdmin: user.isAdmin,
});
});
it('refuses local user creation when requester is not admin', async () => {
const result = await localAuthService.createLocalUser(
{
username: 'jim',
password: 'strong-password',
displayName: 'Jim Halpert',
},
user,
);
expect(result).toEqualLeft({
message: AUTH_ADMIN_REQUIRED,
statusCode: HttpStatus.FORBIDDEN,
});
expect(mockPrisma.user.create).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,227 @@
import { HttpStatus, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as argon2 from 'argon2';
import * as E from 'fp-ts/Either';
import {
AUTH_ADMIN_REQUIRED,
AUTH_INVALID_LOCAL_CREDENTIALS,
AUTH_LOCAL_PROVIDER_DISABLED,
AUTH_LOCAL_SETUP_NOT_ALLOWED,
AUTH_USERNAME_ALREADY_EXISTS,
} from 'src/errors';
import { PrismaService } from 'src/prisma/prisma.service';
import { RESTError } from 'src/types/RESTError';
import { AuthUser } from 'src/types/AuthUser';
import { InfraConfigEnum } from 'src/types/InfraConfig';
import { AuthProvider, authProviderCheck } from './helper';
import { AuthService } from './auth.service';
import {
CreateLocalUserDto,
LocalSetupAdminDto,
LocalSignInDto,
} from './dto/local-auth.dto';
type LocalUserWithCredential = Awaited<
ReturnType<PrismaService['user']['findFirst']>
> & {
localCredential?: {
passwordHash: string;
} | null;
};
@Injectable()
export class LocalAuthService {
constructor(
private readonly prisma: PrismaService,
private readonly authService: AuthService,
private readonly configService: ConfigService,
) {}
normalizeUsername(username: string) {
return username.trim().toLowerCase();
}
private invalidCredentials() {
return E.left(<RESTError>{
message: AUTH_INVALID_LOCAL_CREDENTIALS,
statusCode: HttpStatus.UNAUTHORIZED,
});
}
private async ensureLocalProviderEnabled() {
const allowedProviders = this.configService.get<string>(
'INFRA.VITE_ALLOWED_AUTH_PROVIDERS',
);
if (!authProviderCheck(AuthProvider.LOCAL, allowedProviders)) {
return E.left(<RESTError>{
message: AUTH_LOCAL_PROVIDER_DISABLED,
statusCode: HttpStatus.NOT_FOUND,
});
}
return E.right(true);
}
private async findUserByUsername(username: string) {
return this.prisma.user.findFirst({
where: {
username: {
equals: username,
mode: 'insensitive',
},
},
include: {
localCredential: true,
},
}) as Promise<LocalUserWithCredential | null>;
}
private async ensureUsernameAvailable(username: string) {
const existingUser = await this.prisma.user.findFirst({
where: {
username: {
equals: username,
mode: 'insensitive',
},
},
});
if (existingUser) {
return E.left(<RESTError>{
message: AUTH_USERNAME_ALREADY_EXISTS,
statusCode: HttpStatus.CONFLICT,
});
}
return E.right(true);
}
private async createLocalUserRecord(
username: string,
password: string,
displayName: string,
isAdmin: boolean,
) {
const passwordHash = await argon2.hash(password);
return this.prisma.user.create({
data: {
username,
displayName,
isAdmin,
localCredential: {
create: {
passwordHash,
},
},
providerAccounts: {
create: {
provider: AuthProvider.LOCAL,
providerAccountId: username,
},
},
},
});
}
async setupFirstAdmin(dto: LocalSetupAdminDto) {
const onboardingCompleted = await this.prisma.infraConfig.findUnique({
where: {
name: InfraConfigEnum.ONBOARDING_COMPLETED,
},
});
if (onboardingCompleted?.value === 'true') {
return E.left(<RESTError>{
message: AUTH_LOCAL_SETUP_NOT_ALLOWED,
statusCode: HttpStatus.FORBIDDEN,
});
}
const username = this.normalizeUsername(dto.username);
const existingUser = await this.findUserByUsername(username);
if (existingUser) {
if (existingUser.isAdmin && existingUser.localCredential?.passwordHash) {
const passwordMatches = await argon2.verify(
existingUser.localCredential.passwordHash,
dto.password,
);
if (passwordMatches) {
return this.authService.generateAuthTokens(existingUser.uid);
}
}
return E.left(<RESTError>{
message: AUTH_USERNAME_ALREADY_EXISTS,
statusCode: HttpStatus.CONFLICT,
});
}
const user = await this.createLocalUserRecord(
username,
dto.password,
username,
true,
);
return this.authService.generateAuthTokens(user.uid);
}
async createLocalUser(dto: CreateLocalUserDto, requester: AuthUser) {
if (!requester?.isAdmin) {
return E.left(<RESTError>{
message: AUTH_ADMIN_REQUIRED,
statusCode: HttpStatus.FORBIDDEN,
});
}
const providerEnabled = await this.ensureLocalProviderEnabled();
if (E.isLeft(providerEnabled)) return providerEnabled;
const username = this.normalizeUsername(dto.username);
const usernameAvailable = await this.ensureUsernameAvailable(username);
if (E.isLeft(usernameAvailable)) return usernameAvailable;
const user = await this.createLocalUserRecord(
username,
dto.password,
dto.displayName || username,
false,
);
return E.right({
uid: user.uid,
username: user.username,
displayName: user.displayName,
email: user.email,
photoURL: user.photoURL,
isAdmin: user.isAdmin,
});
}
async signIn(dto: LocalSignInDto) {
const providerEnabled = await this.ensureLocalProviderEnabled();
if (E.isLeft(providerEnabled)) return providerEnabled;
const username = this.normalizeUsername(dto.username);
const user = await this.findUserByUsername(username);
if (!user?.localCredential?.passwordHash) {
return this.invalidCredentials();
}
const passwordMatches = await argon2.verify(
user.localCredential.passwordHash,
dto.password,
);
if (!passwordMatches) {
return this.invalidCredentials();
}
return this.authService.generateAuthTokens(user.uid);
}
}

View file

@ -612,6 +612,40 @@ export const INVALID_ACCESS_TOKEN = 'auth/invalid_access_token' as const;
*/
export const INVALID_REFRESH_TOKEN = 'auth/invalid_refresh_token' as const;
/**
* Local username/password authentication failed
* (LocalAuthService)
*/
export const AUTH_INVALID_LOCAL_CREDENTIALS =
'auth/invalid_local_credentials' as const;
/**
* Local auth provider is not enabled
* (LocalAuthService)
*/
export const AUTH_LOCAL_PROVIDER_DISABLED =
'auth/local_provider_disabled' as const;
/**
* Initial local admin setup is no longer allowed
* (LocalAuthService)
*/
export const AUTH_LOCAL_SETUP_NOT_ALLOWED =
'auth/local_setup_not_allowed' as const;
/**
* Username is already used by another account
* (LocalAuthService)
*/
export const AUTH_USERNAME_ALREADY_EXISTS =
'auth/username_already_exists' as const;
/**
* Admin privileges are required for this auth action
* (LocalAuthService)
*/
export const AUTH_ADMIN_REQUIRED = 'auth/admin_required' as const;
/**
* The provided title for the user collection is short (less than 3 characters)
* (UserCollectionService)

View file

@ -66,6 +66,7 @@ export function getAuthProviderRequiredKeys(
InfraConfigEnum.MICROSOFT_SCOPE,
InfraConfigEnum.MICROSOFT_TENANT,
],
[AuthProvider.LOCAL]: [],
[AuthProvider.EMAIL]:
env['INFRA'].MAILER_USE_CUSTOM_CONFIGS === 'true'
? [

View file

@ -16,6 +16,7 @@ import { PubSubService } from 'src/pubsub/pubsub.service';
import { ServiceStatus } from './helper';
import * as E from 'fp-ts/Either';
import { UserService } from 'src/user/user.service';
import { AuthProvider } from 'src/auth/helper';
const mockPrisma = mockDeep<PrismaService>();
const mockConfigService = mockDeep<ConfigService>();
@ -295,6 +296,19 @@ describe('InfraConfigService', () => {
});
describe('validateEnvValues', () => {
describe('VITE_ALLOWED_AUTH_PROVIDERS', () => {
it('should accept LOCAL in VITE_ALLOWED_AUTH_PROVIDERS', () => {
const result = infraConfigService.validateEnvValues([
{
name: InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS,
value: 'LOCAL',
},
]);
expect(result).toEqualRight(true);
});
});
describe('MAILER_SMTP_AUTH_TYPE', () => {
it('should accept an empty value (defaults to login at runtime)', () => {
const result = infraConfigService.validateEnvValues([
@ -348,6 +362,17 @@ describe('InfraConfigService', () => {
});
});
describe('isServiceConfigured', () => {
it('should consider LOCAL configured without external config', () => {
const result = infraConfigService.isServiceConfigured(
AuthProvider.LOCAL,
{},
);
expect(result).toBe(true);
});
});
describe('getOnboardingConfig', () => {
const RECOVERY_TOKEN = 'valid-recovery-token-123';

View file

@ -321,6 +321,8 @@ export class InfraConfigService implements OnModuleInit, OnModuleDestroy {
} else {
return configMap.MAILER_SMTP_URL && configMap.MAILER_ADDRESS_FROM;
}
case AuthProvider.LOCAL:
return true;
default:
return false;
}

View file

@ -7,6 +7,12 @@ export class User {
})
uid: string;
@Field({
nullable: true,
description: 'Username of the user',
})
username?: string;
@Field({
nullable: true,
description: 'Name of the user (if fetched)',

View file

@ -192,6 +192,7 @@
"continue_with_github_enterprise": "Continue with GitHub Enterprise",
"continue_with_google": "Continue with Google",
"continue_with_microsoft": "Continue with Microsoft",
"continue_with_username": "Continue with username",
"email": "Email",
"logged_out": "Logged out",
"login": "Login",
@ -200,7 +201,9 @@
"logout": "Logout",
"re_enter_email": "Re-enter email",
"send_magic_link": "Send a magic link",
"password": "Password",
"sync": "Sync",
"username": "Username",
"we_sent_magic_link": "We sent you a magic link!",
"we_sent_magic_link_description": "Check your inbox - we sent an email to {email}. It contains a magic link that will log you in."
},

View file

@ -60,6 +60,32 @@
:label="`${t('auth.send_magic_link')}`"
/>
</form>
<form
v-if="mode === 'local'"
class="flex flex-col space-y-2"
@submit.prevent="signInWithUsernamePassword"
>
<HoppSmartInput
v-model="form.username"
type="text"
placeholder=" "
:label="t('auth.username')"
input-styles="floating-input"
/>
<HoppSmartInput
v-model="form.password"
type="password"
placeholder=" "
:label="t('auth.password')"
input-styles="floating-input"
/>
<HoppButtonPrimary
:loading="signingInWithLocal"
type="submit"
:label="`${t('auth.login')}`"
/>
</form>
<div
v-if="!allowedAuthProviders?.length && !additionalLoginItems.length"
@ -116,7 +142,7 @@
label="Privacy Policy"
/>
</div>
<div v-if="mode === 'email'">
<div v-if="mode === 'email' || mode === 'local'">
<HoppButtonSecondary
:label="t('auth.all_sign_in_options')"
:icon="IconArrowLeft"
@ -145,7 +171,7 @@
</template>
<script setup lang="ts">
import { Ref, onMounted, ref } from "vue"
import { Component, Ref, onMounted, ref } from "vue"
import { useI18n } from "@composables/i18n"
import { useStreamSubscriber } from "@composables/stream"
@ -159,6 +185,7 @@ import IconGoogle from "~icons/auth/google"
import IconMicrosoft from "~icons/auth/microsoft"
import IconArrowLeft from "~icons/lucide/arrow-left"
import IconFileText from "~icons/lucide/file-text"
import IconKeyRound from "~icons/lucide/key-round"
import { useService } from "dioc/vue"
import { LoginItemDef } from "~/platform/auth"
@ -178,6 +205,8 @@ const persistenceService = useService(PersistenceService)
const form = {
email: "",
username: "",
password: "",
}
const isLoadingAllowedAuthProviders = ref(true)
@ -186,6 +215,7 @@ const signingInWithGoogle = ref(false)
const signingInWithGitHub = ref(false)
const signingInWithMicrosoft = ref(false)
const signingInWithEmail = ref(false)
const signingInWithLocal = ref(false)
const mode = ref("sign-in")
const tosLink = import.meta.env.VITE_APP_TOS_LINK
@ -193,7 +223,7 @@ const privacyPolicyLink = import.meta.env.VITE_APP_PRIVACY_POLICY_LINK
type AuthProviderItem = {
id: string
icon: typeof IconGithub
icon: Component
label: string
action: (...args: any[]) => any
isLoading: Ref<boolean>
@ -356,6 +386,24 @@ const signInWithEmail = async () => {
})
}
const signInWithUsernamePassword = async () => {
signingInWithLocal.value = true
await platform.auth
.signInWithUsernamePassword(form.username, form.password)
.then(() => {
showLoginSuccess()
hideModal()
})
.catch((e) => {
console.error(e)
toast.error(`${t("error.something_went_wrong")}`)
})
.finally(() => {
signingInWithLocal.value = false
})
}
const authProvidersAvailable: AuthProviderItem[] = [
{
id: "GITHUB",
@ -395,6 +443,15 @@ const authProvidersAvailable: AuthProviderItem[] = [
},
isLoading: signingInWithEmail,
},
{
id: "LOCAL",
icon: IconKeyRound,
label: t("auth.continue_with_username"),
action: () => {
mode.value = "local"
},
isLoading: signingInWithLocal,
},
]
const hideModal = () => {

View file

@ -186,6 +186,17 @@ export type AuthPlatformDef = {
*/
signInWithEmail: (email: string) => Promise<void>
/**
* Called to sign in user with username and password.
* @param username The username that is logging in.
* @param password The password for the local account.
* @returns An empty promise that is resolved when the operation is complete
*/
signInWithUsernamePassword: (
username: string,
password: string
) => Promise<void>
/**
* Check whether a given link is a valid sign in with email, magic link response url.
* (i.e, a URL that COULD be from a magic link email)
@ -261,7 +272,7 @@ export type AuthPlatformDef = {
) => Promise<E.Either<GQLError<string>, undefined>>
/**
* Returns the list of allowed auth providers for the platform ( the currently supported ones are GOOGLE, GITHUB, EMAIL, MICROSOFT, SAML )
* Returns the list of allowed auth providers for the platform ( the currently supported ones are GOOGLE, GITHUB, EMAIL, MICROSOFT, SAML, LOCAL )
*/
getAllowedAuthProviders: () => Promise<E.Either<string, string[]>>

View file

@ -9,7 +9,7 @@ import {
} from "@app/api/generated/graphql"
const expectedAllowedProvidersSchema = z.object({
// currently supported values are "GOOGLE", "GITHUB", "EMAIL", "MICROSOFT", "SAML"
// currently supported values are "GOOGLE", "GITHUB", "EMAIL", "MICROSOFT", "SAML", "LOCAL"
// keeping it as string to avoid backend accidentally breaking frontend when adding new providers
providers: z.array(z.string()),
})

View file

@ -429,6 +429,25 @@ export const def: AuthPlatformDef = {
await sendMagicLink(email)
},
async signInWithUsernamePassword(username: string, password: string) {
const { response } = interceptorService.execute({
id: Date.now(),
url: `${import.meta.env.VITE_BACKEND_API_URL}/auth/local/signin`,
version: "HTTP/1.1",
method: "POST",
headers: {
"Content-Type": "application/json",
},
content: content.json({ username, password }),
})
const res = await response
if (E.isLeft(res)) throw new Error("Failed to sign in")
await setAuthCookies(res.right.headers)
await setInitialUser()
},
async verifyEmailAddress() {
return
},

View file

@ -9,7 +9,7 @@ import {
} from "@app/api/generated/graphql"
const expectedAllowedProvidersSchema = z.object({
// currently supported values are "GOOGLE", "GITHUB", "EMAIL", "MICROSOFT", "SAML"
// currently supported values are "GOOGLE", "GITHUB", "EMAIL", "MICROSOFT", "SAML", "LOCAL"
// keeping it as string to avoid backend accidentally breaking frontend when adding new providers
providers: z.array(z.string()),
})
@ -35,6 +35,19 @@ export const getAllowedAuthProviders = async () => {
}
}
export const signInLocal = async (username: string, password: string) => {
await axios.post(
`${import.meta.env.VITE_BACKEND_API_URL}/auth/local/signin`,
{
username,
password,
},
{
withCredentials: true,
}
)
}
export const updateUserDisplayName = (updatedDisplayName: string) =>
runMutation<
UpdateUserDisplayNameMutation,

View file

@ -11,7 +11,11 @@ import {
} from "@hoppscotch/common/platform/auth"
import { PersistenceService } from "@hoppscotch/common/services/persistence"
import { getAllowedAuthProviders, updateUserDisplayName } from "./api"
import {
getAllowedAuthProviders,
signInLocal,
updateUserDisplayName,
} from "./api"
export const authEvents$ = new Subject<AuthEvent | { event: "token_refresh" }>()
const currentUser$ = new BehaviorSubject<HoppUser | null>(null)
@ -276,6 +280,11 @@ export const def: AuthPlatformDef = {
await sendMagicLink(email)
},
async signInWithUsernamePassword(username: string, password: string) {
await signInLocal(username, password)
await setInitialUser()
},
isSignInWithEmailLink(url: string) {
const urlObject = new URL(url)
const searchParams = new URLSearchParams(urlObject.search)

View file

@ -24,6 +24,9 @@
"email_auth_enabled": "Email authentication is enabled and ready to use.",
"email_auth_smtp_note": "To use email-based authentication, you need to enable and configure SMTP in the SMTP tab first.",
"enable_email_auth": "Enable Email Authentication",
"enable_local_auth": "Enable Username and Password",
"local_auth": "Username and Password",
"local_auth_description": "Enable or disable local username and password authentication for your users.",
"smtp_required": "SMTP must be enabled and configured to use email based authentication."
},
"configs": {
@ -36,6 +39,7 @@
"description": "Configure authentication providers for your server",
"email": "Email",
"github_enterprise": "Enable Github Enterprise",
"local": "Username and Password",
"oauth": "OAuth",
"oauth_providers": "OAuth Providers",
"provider_not_specified": "Please enable at least one authentication provider",
@ -220,6 +224,19 @@
"description_accordian": "Select the OAuth providers you want to enable and provide the necessary configurations.",
"title": "OAuth"
},
"local": {
"confirm_password": "Confirm password",
"description": "Use usernames and passwords without an external service.",
"description_accordian": "Create the first local administrator account.",
"password": "Password",
"password_min_length": "Password must be at least 12 characters.",
"password_mismatch": "Passwords do not match.",
"setup_failed": "Failed to create the local administrator account.",
"title": "Username and password",
"username": "Username",
"username_format": "Username can only contain letters, numbers, dots, dashes, and underscores.",
"username_min_length": "Username must be at least 3 characters."
},
"onboarding_incomplete": {
"description": "You have not completed the onboarding process. Please set up at least one authentication provider to continue.",
"title": "Onboarding Incomplete"
@ -292,6 +309,7 @@
"continue_email": "Continue with Email",
"continue_github": "Continue with Github",
"continue_google": "Continue with Google",
"continue_local": "Continue with username",
"continue_microsoft": "Continue with Microsoft",
"copied_to_clipboard": "Copied to clipboard",
"create_team_failure": "Failed to create workspace!!",
@ -332,6 +350,7 @@
"login_using_email": "Please ask the user to check their email or share the link below",
"login_using_link": "Please ask the user to login using the link below",
"logout": "Logout",
"local_signin_failure": "Failed to login with username and password",
"magic_link_sign_in": "Click on the link to sign in.",
"magic_link_success": "We sent a magic link to",
"microsoft_signin_failure": "Failed to login with Microsoft",
@ -339,6 +358,7 @@
"non_admin_logged_in": "Logged in as non admin user.",
"non_admin_login": "You are logged in. But you're not an admin",
"owner_not_present": "Atleast one owner should be present in the team!!",
"password": "Password",
"privacy_policy": "Privacy Policy",
"reenter_email": "Re-enter email",
"remove_admin_failure": "Failed to remove admin status!!",
@ -367,12 +387,14 @@
"send_magic_link": "Send magic link",
"setup_failure": "Setup has failed!!",
"setup_success": "Setup completed successfully!!",
"sign_in": "Sign in",
"sign_in_agreement": "By signing in, you are agreeing to our",
"sign_in_options": "All sign in option",
"sign_out": "Sign out",
"something_went_wrong": "Something went wrong",
"team_name_too_short": "Workspace name should be atleast 6 characters long!!",
"user_already_invited": "Failed to send invite. User is already invited!!",
"username": "Username",
"user_not_found": "User not found in the infra!!",
"users_to_admin_success": "Selected users are elevated to admin status!!",
"users_to_admin_failure": "Failed to elevate selected users to admin status!!"
@ -459,6 +481,8 @@
"created_on": "Created On",
"copy_invite_link": "Copy Invite Link",
"copy_link": "Copy Link",
"confirm_password": "Confirm Password",
"create_local_user": "Create Local User",
"date": "Date",
"delete": "Delete",
"delete_user": "Delete User",
@ -480,6 +504,8 @@
"last_active_on": "Last Active",
"load_info_error": "Unable to load user info",
"load_list_error": "Unable to Load Users List",
"local_user_create_failed": "Failed to create local user",
"local_user_created": "Local user created",
"make_admin": "Make Admin",
"name": "Name",
"new_user_added": "New User Added",
@ -490,6 +516,9 @@
"not_found": "User not found",
"pending_invites": "Pending Invites",
"pending_invites_description": "Manage and track pending user invitations with clear status and actions.",
"password": "Password",
"password_min_length": "Password must be at least 12 characters.",
"password_mismatch": "Passwords do not match.",
"remove_admin_privilege": "Remove Admin Privilege",
"remove_admin_status": "Remove Admin Status",
"rename": "Rename",
@ -499,6 +528,9 @@
"show_more": "Show more",
"uid": "UID",
"unnamed": "(Unnamed User)",
"username": "Username",
"username_format": "Username can only contain letters, numbers, dots, dashes, and underscores.",
"username_min_length": "Username must be at least 3 characters.",
"user_not_found": "User not found in the infra!!",
"users": "Users",
"valid_email": "Please enter a valid email address"

View file

@ -1,3 +1,46 @@
{
"auth": {
"enable_local_auth": "Activer identifiant et mot de passe",
"local_auth": "Identifiant et mot de passe",
"local_auth_description": "Activer ou désactiver lauthentification locale par identifiant et mot de passe pour les utilisateurs."
},
"configs": {
"auth_providers": {
"local": "Identifiant et mot de passe"
}
},
"onboarding": {
"local": {
"confirm_password": "Confirmer le mot de passe",
"description": "Utiliser des identifiants sans service externe.",
"description_accordian": "Créer le premier compte administrateur local.",
"password": "Mot de passe",
"password_min_length": "Le mot de passe doit contenir au moins 12 caractères.",
"password_mismatch": "Les mots de passe ne correspondent pas.",
"setup_failed": "Impossible de créer le compte administrateur local.",
"title": "Identifiant et mot de passe",
"username": "Identifiant",
"username_format": "Lidentifiant ne peut contenir que des lettres, chiffres, points, tirets et underscores.",
"username_min_length": "Lidentifiant doit contenir au moins 3 caractères."
}
},
"state": {
"continue_local": "Continuer avec un identifiant",
"local_signin_failure": "Connexion avec identifiant et mot de passe impossible",
"password": "Mot de passe",
"sign_in": "Se connecter",
"username": "Identifiant"
},
"users": {
"confirm_password": "Confirmer le mot de passe",
"create_local_user": "Créer un utilisateur local",
"local_user_create_failed": "Impossible de créer lutilisateur local",
"local_user_created": "Utilisateur local créé",
"password": "Mot de passe",
"password_min_length": "Le mot de passe doit contenir au moins 12 caractères.",
"password_mismatch": "Les mots de passe ne correspondent pas.",
"username": "Identifiant",
"username_format": "Lidentifiant ne peut contenir que des lettres, chiffres, points, tirets et underscores.",
"username_min_length": "Lidentifiant doit contenir au moins 3 caractères."
}
}

View file

@ -51,6 +51,12 @@
:label="t('state.continue_email')"
@click="mode = 'email'"
/>
<HoppSmartItem
v-if="allowedAuthProviders.includes('LOCAL')"
:icon="IconKeyRound"
:label="t('state.continue_local')"
@click="mode = 'local'"
/>
</div>
<form
v-if="mode === 'email' && allowedAuthProviders"
@ -71,6 +77,32 @@
:label="t('state.send_magic_link')"
/>
</form>
<form
v-if="mode === 'local' && allowedAuthProviders"
class="flex flex-col space-y-4"
@submit.prevent="signInWithUsernamePassword"
>
<HoppSmartInput
v-model="form.username"
type="text"
placeholder=" "
input-styles="floating-input"
:label="t('state.username')"
/>
<HoppSmartInput
v-model="form.password"
type="password"
placeholder=" "
input-styles="floating-input"
:label="t('state.password')"
/>
<HoppButtonPrimary
:loading="signingInWithLocal"
type="submit"
:label="t('state.sign_in')"
/>
</form>
<div
v-if="!allowedAuthProviders?.length"
class="flex flex-col items-center text-center"
@ -139,7 +171,7 @@
:label="t('state.privacy_policy')"
/>
</div>
<div v-if="mode === 'email'">
<div v-if="mode === 'email' || mode === 'local'">
<HoppButtonSecondary
:label="t('state.sign_in_options')"
:icon="IconArrowLeft"
@ -175,6 +207,7 @@ import IconGoogle from '~icons/auth/google';
import IconMicrosoft from '~icons/auth/microsoft';
import IconArrowLeft from '~icons/lucide/arrow-left';
import IconFileText from '~icons/lucide/file-text';
import IconKeyRound from '~icons/lucide/key-round';
const t = useI18n();
const toast = useToast();
@ -184,6 +217,8 @@ const privacyPolicyLink = import.meta.env.VITE_APP_PRIVACY_POLICY_LINK;
const form = ref({
email: '',
username: '',
password: '',
});
const fetching = ref(false);
const error = ref(false);
@ -191,6 +226,7 @@ const signingInWithGoogle = ref(false);
const signingInWithGitHub = ref(false);
const signingInWithMicrosoft = ref(false);
const signingInWithEmail = ref(false);
const signingInWithLocal = ref(false);
const mode = ref('sign-in');
const nonAdminUser = ref(false);
@ -265,6 +301,26 @@ const signInWithEmail = async () => {
signingInWithEmail.value = false;
};
const signInWithUsernamePassword = async () => {
signingInWithLocal.value = true;
try {
await auth.signInWithUsernamePassword(
form.value.username,
form.value.password,
);
if (auth.getCurrentUser()?.isAdmin) {
window.location.href = import.meta.env.VITE_ADMIN_URL;
} else {
nonAdminUser.value = true;
}
} catch (e) {
console.error(e);
toast.error(t('state.local_signin_failure'));
}
signingInWithLocal.value = false;
};
const getAllowedAuthProviders = async () => {
fetching.value = true;
try {

View file

@ -67,6 +67,17 @@
class="relative inline-block h-6 w-6 rounded-full border-2 border-primary object-cover object-center hover:z-10 focus:z-10"
/>
</AuthProviderCard>
<AuthProviderCard
title="onboarding.local.title"
description="onboarding.local.description"
:selected="isSelected('LOCAL') && isLocalEnabled"
@click="toggleSelectedOption('LOCAL')"
>
<IconLucideKeyRound
class="relative inline-block h-6 w-6 rounded-full border-2 border-primary bg-primaryDark p-1 text-accent hover:z-10 focus:z-10"
/>
</AuthProviderCard>
</div>
<HoppButtonPrimary
@ -115,6 +126,41 @@
v-model:enabledConfigs="enabledConfigs"
/>
</UiAccordion>
<UiAccordion
v-if="isSelected('LOCAL') && isFirstTimeSetup"
:initial-open="isLocalEnabled"
title="onboarding.local.title"
description="onboarding.local.description_accordian"
@toggle="toggleConfig('LOCAL')"
class="bg-primary rounded-lg border-primaryDark shadow p-4 border flex flex-col"
>
<div class="flex flex-col space-y-2 py-4">
<HoppSmartInput
v-model="localAdminCredentials.username"
:label="t('onboarding.local.username')"
input-styles="floating-input"
:autofocus="false"
class="!my-2 !bg-primaryLight flex-1"
/>
<HoppSmartInput
v-model="localAdminCredentials.password"
:label="t('onboarding.local.password')"
input-styles="floating-input"
:autofocus="false"
type="password"
class="!my-2 !bg-primaryLight flex-1"
/>
<HoppSmartInput
v-model="localAdminCredentials.confirmPassword"
:label="t('onboarding.local.confirm_password')"
input-styles="floating-input"
:autofocus="false"
type="password"
class="!my-2 !bg-primaryLight flex-1"
/>
</div>
</UiAccordion>
</div>
<div class="flex items-center space-x-4 mt-6">
@ -163,6 +209,7 @@ import SmtpSetup from './SmtpSetup.vue';
import IconLucideArrowRight from '~icons/lucide/arrow-right';
import IconLucideArrowLeft from '~icons/lucide/arrow-left';
import IconLucideSave from '~icons/lucide/save';
import IconLucideKeyRound from '~icons/lucide/key-round';
import AuthProviderCard from './AuthProviderCard.vue';
const t = useI18n();
@ -176,7 +223,7 @@ const emit = defineEmits<{
): void;
}>();
type SelectedOption = 'OAUTH' | 'SMTP';
type SelectedOption = 'OAUTH' | 'SMTP' | 'LOCAL';
const authConfigStep = ref(1);
const selectedOptions = ref<SelectedOption[]>([]);
@ -184,6 +231,7 @@ const selectedOptions = ref<SelectedOption[]>([]);
const {
currentConfigs,
enabledConfigs,
localAdminCredentials,
isProvidersLoading,
submittingConfigs,
onBoardingSummary,
@ -240,6 +288,7 @@ const isSmtpEnabled = computed(
enabledConfigs.value.includes('MAILER') ||
enabledConfigs.value.includes('EMAIL'),
);
const isLocalEnabled = computed(() => enabledConfigs.value.includes('LOCAL'));
const updateOAuthEnabled = () => {
isOAuthEnabled.value = OAuthProviders.some((provider) =>
@ -263,6 +312,10 @@ const toggleSelectedOption = (option: SelectedOption) => {
toggleConfig('EMAIL');
toggleSmtpConfig();
}
} else if (option === 'LOCAL') {
if (willBeSelected !== isLocalEnabled.value) {
toggleConfig('LOCAL');
}
} else if (willBeSelected !== isOAuthEnabled.value) {
toggleConfig(option);
}
@ -277,7 +330,10 @@ const proceedToConfig = () => {
};
const submitConfigs = async () => {
const res = await addOnBoardingConfigs();
const res = await addOnBoardingConfigs({
setupLocalAdmin:
props.isFirstTimeSetup === true && enabledConfigs.value.includes('LOCAL'),
});
if (res?.token) {
emit('complete-onboarding', {
submittingConfigs: submittingConfigs.value,

View file

@ -62,6 +62,36 @@
</div>
</HoppSmartTab>
<HoppSmartTab id="local-auth" :label="t('configs.auth_providers.local')">
<div class="pb-8 px-4 grid md:grid-cols-3 gap-4 md:gap-4 pt-8">
<div class="md:col-span-1">
<h3 class="heading">{{ t('auth.local_auth') }}</h3>
<p class="my-1 text-secondaryLight">
{{ t('auth.local_auth_description') }}
</p>
</div>
<div class="sm:px-8 md:col-span-2">
<section>
<h4 class="font-semibold text-secondaryDark">
{{ t('auth.local_auth') }}
</h4>
<div class="space-y-4 py-4">
<div class="flex justify-between">
<HoppSmartToggle
:on="isLocalAuthEnabled"
@change="toggleLocalAuth"
>
{{ t('auth.enable_local_auth') }}
</HoppSmartToggle>
</div>
</div>
</section>
</div>
</div>
</HoppSmartTab>
<HoppSmartTab id="token" :label="t('configs.auth_providers.token.title')">
<div class="pb-8 px-4">
<SettingsAuthToken v-model:config="workingConfigs" />
@ -88,14 +118,14 @@ const emit = defineEmits<{
}>();
// Auth Sub Tabs
type AuthSubTabs = 'auth-providers' | 'email-auth' | 'token';
type AuthSubTabs = 'auth-providers' | 'email-auth' | 'local-auth' | 'token';
const selectedAuthSubTab = ref<AuthSubTabs>('auth-providers');
const workingConfigs = useVModel(props, 'config', emit);
// Check if SMTP is activated but not saved yet. Used to track if SMTP was enabled after the last save.
const isSMTPActivated = computed(
() => workingConfigs.value?.mailConfigs.enabled ?? false
() => workingConfigs.value?.mailConfigs.enabled ?? false,
);
// Check if Email authentication is enabled
@ -111,6 +141,15 @@ const toggleEmailAuth = () => {
}
};
const isLocalAuthEnabled = computed(() => {
return workingConfigs.value?.localAuth.enabled ?? false;
});
const toggleLocalAuth = () => {
workingConfigs.value.localAuth.enabled =
!workingConfigs.value.localAuth.enabled;
};
// Check if SMTP is enabled on mount
const isSMTPEnabled = ref(false);

View file

@ -38,7 +38,7 @@ import { useClientHandler } from './useClientHandler';
const COOKIE_NAME_REGEX = /^[A-Za-z0-9_-]+$/;
const OPTIONAL_TOKEN_FIELD_KEYS = new Set(
TOKEN_VALIDATION_CONFIGS.filter((cfg) => cfg.optional).map((cfg) => cfg.key)
TOKEN_VALIDATION_CONFIGS.filter((cfg) => cfg.optional).map((cfg) => cfg.key),
);
/** Composable that handles all operations related to server configurations
@ -58,10 +58,10 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
InfraConfigsDocument,
{
configNames: ALL_CONFIGS.flat().map(
({ name }) => name
({ name }) => name,
) as InfraConfigEnum[],
},
(x) => x.infraConfigs
(x) => x.infraConfigs,
);
// Fetching allowed auth providers
@ -73,7 +73,7 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
} = useClientHandler(
AllowedAuthProvidersDocument,
{},
(x) => x.allowedAuthProviders
(x) => x.allowedAuthProviders,
);
// Current and working configs
@ -133,7 +133,7 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
mailer_smtp_port: getFieldValue(InfraConfigEnum.MailerSmtpPort),
mailer_smtp_user: getFieldValue(InfraConfigEnum.MailerSmtpUser),
mailer_smtp_password: getFieldValue(
InfraConfigEnum.MailerSmtpPassword
InfraConfigEnum.MailerSmtpPassword,
),
mailer_smtp_secure:
getFieldValue(InfraConfigEnum.MailerSmtpSecure) === 'true',
@ -147,37 +147,42 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
mailer_smtp_auth_type:
getFieldValue(InfraConfigEnum.MailerSmtpAuthType) || 'login',
mailer_smtp_oauth2_user: getFieldValue(
InfraConfigEnum.MailerSmtpOauth2User
InfraConfigEnum.MailerSmtpOauth2User,
),
mailer_smtp_oauth2_client_id: getFieldValue(
InfraConfigEnum.MailerSmtpOauth2ClientId
InfraConfigEnum.MailerSmtpOauth2ClientId,
),
mailer_smtp_oauth2_client_secret: getFieldValue(
InfraConfigEnum.MailerSmtpOauth2ClientSecret
InfraConfigEnum.MailerSmtpOauth2ClientSecret,
),
mailer_smtp_oauth2_refresh_token: getFieldValue(
InfraConfigEnum.MailerSmtpOauth2RefreshToken
InfraConfigEnum.MailerSmtpOauth2RefreshToken,
),
mailer_smtp_oauth2_access_url: getFieldValue(
InfraConfigEnum.MailerSmtpOauth2AccessUrl
InfraConfigEnum.MailerSmtpOauth2AccessUrl,
),
},
},
localAuth: {
name: 'local',
enabled: allowedAuthProviders.value.includes(AuthProvider.Local),
fields: {},
},
tokenConfigs: {
name: 'token',
fields: {
jwt_secret: getFieldValue(InfraConfigEnum.JwtSecret),
token_salt_complexity: getFieldValue(
InfraConfigEnum.TokenSaltComplexity
InfraConfigEnum.TokenSaltComplexity,
),
magic_link_token_validity: getFieldValue(
InfraConfigEnum.MagicLinkTokenValidity
InfraConfigEnum.MagicLinkTokenValidity,
),
refresh_token_validity: getFieldValue(
InfraConfigEnum.RefreshTokenValidity
InfraConfigEnum.RefreshTokenValidity,
),
access_token_validity: getFieldValue(
InfraConfigEnum.AccessTokenValidity
InfraConfigEnum.AccessTokenValidity,
),
session_secret: getFieldValue(InfraConfigEnum.SessionSecret),
session_cookie_name: getFieldValue(InfraConfigEnum.SessionCookieName),
@ -186,7 +191,7 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
dataSharingConfigs: {
name: 'data_sharing',
enabled: !!infraConfigs.value.find(
(x) => x.name === 'ALLOW_ANALYTICS_COLLECTION' && x.value === 'true'
(x) => x.name === 'ALLOW_ANALYTICS_COLLECTION' && x.value === 'true',
),
},
historyConfig: {
@ -194,7 +199,7 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
enabled: !!infraConfigs.value.find(
(config) =>
config.name === 'USER_HISTORY_STORE_ENABLED' &&
config.value === 'ENABLE'
config.value === 'ENABLE',
),
},
rateLimitConfigs: {
@ -208,7 +213,7 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
name: 'mock_server',
fields: {
mock_server_wildcard_domain: getFieldValue(
InfraConfigEnum.MockServerWildcardDomain
InfraConfigEnum.MockServerWildcardDomain,
),
},
},
@ -316,8 +321,7 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
return (
section.enabled &&
Object.entries(otherFields).some(
([key, value]) =>
isFieldEmpty(value) && !excludeKeys.includes(key)
([key, value]) => isFieldEmpty(value) && !excludeKeys.includes(key),
)
);
}
@ -327,7 +331,7 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
if (section.name === 'token') {
return Object.entries(section.fields).some(
([key, value]) =>
!OPTIONAL_TOKEN_FIELD_KEYS.has(key) && isFieldNotValid(value)
!OPTIONAL_TOKEN_FIELD_KEYS.has(key) && isFieldNotValid(value),
);
}
@ -368,20 +372,20 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
(x) =>
x.key === key &&
key !== 'mailer_smtp_url' &&
key !== 'mailer_smtp_enabled'
key !== 'mailer_smtp_enabled',
);
} else
return MAIL_CONFIGS.some(
(x) => x.key === key && key !== 'mailer_smtp_enabled'
(x) => x.key === key && key !== 'mailer_smtp_enabled',
);
})
}),
);
// Extract the custom mail config fields
const customMailConfigFields = Object.fromEntries(
Object.entries(updatedConfigs?.mailConfigs.fields ?? {}).filter(([key]) =>
CUSTOM_MAIL_CONFIGS.some((x) => x.key === key)
)
CUSTOM_MAIL_CONFIGS.some((x) => x.key === key),
),
);
// Transforming the working configs back into the format required by the mutations
@ -441,7 +445,7 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
const executeMutation = async <T, V>(
mutation: UseMutationResponse<T>,
variables: AnyVariables = undefined,
errorMessage: string
errorMessage: string,
): Promise<boolean> => {
const result = await mutation.executeMutation(variables);
@ -460,7 +464,7 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
// Updating the auth provider configurations
const updateAuthProvider = (
updateProviderStatus: UseMutationResponse<EnableAndDisableSsoMutation>
updateProviderStatus: UseMutationResponse<EnableAndDisableSsoMutation>,
) => {
const updatedAllowedAuthProviders: EnableAndDisableSsoArgs[] = [
{
@ -487,6 +491,12 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
? ServiceStatus.Enable
: ServiceStatus.Disable,
},
{
provider: AuthProvider.Local,
status: updatedConfigs?.localAuth.enabled
? ServiceStatus.Enable
: ServiceStatus.Disable,
},
];
return executeMutation(
@ -494,13 +504,13 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
{
providerInfo: updatedAllowedAuthProviders,
},
'configs.auth_providers.update_failure'
'configs.auth_providers.update_failure',
);
};
// Updating the infra configurations
const updateInfraConfigs = (
updateInfraConfigsMutation: UseMutationResponse<UpdateInfraConfigsMutation>
updateInfraConfigsMutation: UseMutationResponse<UpdateInfraConfigsMutation>,
) => {
const infraConfigs: InfraConfigArgs[] = updatedConfigs
? transformInfraConfigs()
@ -511,23 +521,23 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
{
infraConfigs,
},
'configs.update_failure'
'configs.update_failure',
);
};
// Resetting the infra configurations
const resetInfraConfigs = (
resetInfraConfigsMutation: UseMutationResponse<ResetInfraConfigsMutation>
resetInfraConfigsMutation: UseMutationResponse<ResetInfraConfigsMutation>,
) =>
executeMutation(
resetInfraConfigsMutation,
undefined,
'configs.reset.failure'
'configs.reset.failure',
);
// Toggle Data Sharing
const updateDataSharingConfigs = (
toggleDataSharingMutation: UseMutationResponse<ToggleAnalyticsCollectionMutation>
toggleDataSharingMutation: UseMutationResponse<ToggleAnalyticsCollectionMutation>,
) =>
executeMutation(
toggleDataSharingMutation,
@ -536,12 +546,12 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
? ServiceStatus.Enable
: ServiceStatus.Disable,
},
'configs.data_sharing.update_failure'
'configs.data_sharing.update_failure',
);
// Toggle SMTP
const toggleSMTPConfigs = (
toggleSMTP: UseMutationResponse<ToggleSmtpMutation>
toggleSMTP: UseMutationResponse<ToggleSmtpMutation>,
) =>
executeMutation(
toggleSMTP,
@ -550,12 +560,12 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
? ServiceStatus.Enable
: ServiceStatus.Disable,
},
'configs.mail_configs.toggle_failure'
'configs.mail_configs.toggle_failure',
);
// Toggle User History Store
const toggleUserHistoryStore = (
toggleUserHistoryStore: UseMutationResponse<ToggleUserHistoryStoreMutation>
toggleUserHistoryStore: UseMutationResponse<ToggleUserHistoryStoreMutation>,
) =>
executeMutation(
toggleUserHistoryStore,
@ -564,11 +574,11 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
? ServiceStatus.Enable
: ServiceStatus.Disable,
},
'configs.user_history_store.toggle_failure'
'configs.user_history_store.toggle_failure',
);
const updateRateLimitConfigs = (
updateRateLimitMutation: UseMutationResponse<UpdateInfraConfigsMutation>
updateRateLimitMutation: UseMutationResponse<UpdateInfraConfigsMutation>,
) => {
if (!updatedConfigs?.rateLimitConfigs) {
toast.error(t('configs.rate_limit.input_validation_error'));
@ -576,10 +586,10 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
}
const rateLimitTtl = String(
updatedConfigs?.rateLimitConfigs.fields.rate_limit_ttl
updatedConfigs?.rateLimitConfigs.fields.rate_limit_ttl,
);
const rateLimitMax = String(
updatedConfigs?.rateLimitConfigs.fields.rate_limit_max
updatedConfigs?.rateLimitConfigs.fields.rate_limit_max,
);
if (isFieldEmpty(rateLimitTtl) || isFieldEmpty(rateLimitMax)) {
@ -603,12 +613,12 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
{
infraConfigs: rateLimitConfigs,
},
'configs.rate_limit.update_failure'
'configs.rate_limit.update_failure',
);
};
const updateAuthTokenConfigs = (
updateAuthTokenMutation: UseMutationResponse<UpdateInfraConfigsMutation>
updateAuthTokenMutation: UseMutationResponse<UpdateInfraConfigsMutation>,
) => {
if (!updatedConfigs?.tokenConfigs) {
toast.error(t('configs.auth_providers.token.update_failure'));
@ -617,26 +627,28 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
const jwtSecret = String(updatedConfigs?.tokenConfigs.fields.jwt_secret);
const tokenSaltComplexity = String(
updatedConfigs?.tokenConfigs.fields.token_salt_complexity
updatedConfigs?.tokenConfigs.fields.token_salt_complexity,
);
const magicLinkTokenValidity = String(
updatedConfigs?.tokenConfigs.fields.magic_link_token_validity
updatedConfigs?.tokenConfigs.fields.magic_link_token_validity,
);
const refreshTokenValidity = String(
updatedConfigs?.tokenConfigs.fields.refresh_token_validity
updatedConfigs?.tokenConfigs.fields.refresh_token_validity,
);
const accessTokenValidity = String(
updatedConfigs?.tokenConfigs.fields.access_token_validity
updatedConfigs?.tokenConfigs.fields.access_token_validity,
);
const sessionSecret = String(
updatedConfigs?.tokenConfigs.fields.session_secret
updatedConfigs?.tokenConfigs.fields.session_secret,
);
const sessionCookieName = String(
updatedConfigs?.tokenConfigs.fields.session_cookie_name || ''
updatedConfigs?.tokenConfigs.fields.session_cookie_name || '',
);
// Validate cookie name: allow empty (falls back to default), else enforce pattern
if (sessionCookieName && !COOKIE_NAME_REGEX.test(sessionCookieName)) {
toast.error(t('configs.auth_providers.token.session_cookie_name_invalid'));
toast.error(
t('configs.auth_providers.token.session_cookie_name_invalid'),
);
return false;
}
if (
@ -687,7 +699,7 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
{
infraConfigs: authTokenConfigs,
},
'configs.auth_providers.token.update_failure'
'configs.auth_providers.token.update_failure',
);
};

View file

@ -7,7 +7,18 @@ import { getLocalConfig, setLocalConfig } from '~/helpers/localpersistence';
import { makeReadableKey } from '~/helpers/utils/readableKey';
export type OAuthProvider = 'GOOGLE' | 'GITHUB' | 'MICROSOFT';
export type EnabledConfig = OAuthProvider | 'OAUTH' | 'MAILER' | 'EMAIL';
export type EnabledConfig =
| OAuthProvider
| 'OAUTH'
| 'MAILER'
| 'EMAIL'
| 'LOCAL';
export type LocalAdminCredentials = {
username: string;
password: string;
confirmPassword: string;
};
// common OAuth keys used across providers
type OAuthKeys = 'CLIENT_ID' | 'CLIENT_SECRET' | 'CALLBACK_URL' | 'SCOPE';
@ -124,6 +135,11 @@ export function useOnboardingConfigHandler() {
const enabledConfigs = ref<EnabledConfig[]>([]);
const isProvidersLoading = ref(false);
const submittingConfigs = ref(false);
const localAdminCredentials = ref<LocalAdminCredentials>({
username: '',
password: '',
confirmPassword: '',
});
const onBoardingSummary = ref<OnBoardingSummary>({
type: 'success',
@ -334,6 +350,36 @@ export function useOnboardingConfigHandler() {
);
};
const validateLocalAdminCredentials = () => {
if (!enabledConfigs.value.includes('LOCAL')) return true;
const username = localAdminCredentials.value.username.trim();
const password = localAdminCredentials.value.password;
const confirmPassword = localAdminCredentials.value.confirmPassword;
if (username.length < 3) {
toast.error(t('onboarding.local.username_min_length'));
return false;
}
if (!/^[a-zA-Z0-9_.-]+$/.test(username)) {
toast.error(t('onboarding.local.username_format'));
return false;
}
if (password.length < 12) {
toast.error(t('onboarding.local.password_min_length'));
return false;
}
if (password !== confirmPassword) {
toast.error(t('onboarding.local.password_mismatch'));
return false;
}
return true;
};
/**
* Adds the onboarding configs to the backend.
* It validates the configs, prepares the payload,
@ -341,7 +387,9 @@ export function useOnboardingConfigHandler() {
* We set the token in localStorage for re-fetching configs later.
* @returns The token for re-fetching configs or undefined if failed
*/
const addOnBoardingConfigs = async () => {
const addOnBoardingConfigs = async (options?: {
setupLocalAdmin?: boolean;
}) => {
submittingConfigs.value = true;
const payload = {
...currentConfigs.value.oAuthProviders.GOOGLE,
@ -350,25 +398,46 @@ export function useOnboardingConfigHandler() {
...currentConfigs.value.mailerConfigs,
};
const filteredEnabledConfigs = enabledConfigs.value.filter(
(config) => config !== 'OAUTH' && config !== 'MAILER',
);
const hasLocalAuth = filteredEnabledConfigs.includes('LOCAL');
const validated = validateConfigs(payload);
if (!validated || Object.keys(validated).length === 0) {
if (!validated) {
submittingConfigs.value = false;
return;
}
if (Object.keys(validated).length === 0 && !hasLocalAuth) {
toast.error(t('onboarding.add_atleast_one_auth_provider'));
submittingConfigs.value = false;
return;
}
const filteredEnabledConfigs = enabledConfigs.value.filter(
(config) => config !== 'OAUTH' && config !== 'MAILER',
);
if (options?.setupLocalAdmin && !validateLocalAdminCredentials()) {
submittingConfigs.value = false;
return;
}
const configWithAuth = {
...validated,
...(validated ?? {}),
[InfraConfigEnum.ViteAllowedAuthProviders]:
filteredEnabledConfigs.join(','),
};
try {
if (hasLocalAuth && options?.setupLocalAdmin) {
const isLocalAdminCreated = await auth.setupLocalAdmin(
localAdminCredentials.value.username.trim(),
localAdminCredentials.value.password,
);
if (!isLocalAdminCreated) {
throw new Error(t('onboarding.local.setup_failed'));
}
}
const res = await auth.addOnBoardingConfigs(configWithAuth);
if (res?.token) {
setLocalConfig('access_token', res.token);
@ -453,6 +522,7 @@ export function useOnboardingConfigHandler() {
return {
currentConfigs,
enabledConfigs,
localAdminCredentials,
isProvidersLoading,
onBoardingSummary,
submittingConfigs,

View file

@ -212,6 +212,34 @@ export const auth = {
return authQuery.signInWithEmailLink(token, deviceIdentifier);
},
setupLocalAdmin: async (username: string, password: string) => {
try {
await authQuery.setupLocalAdmin(username, password);
return true;
} catch (err) {
console.error(err);
return false;
}
},
signInWithUsernamePassword: async (username: string, password: string) => {
await authQuery.signInLocal(username, password);
await setInitialUser();
},
createLocalUser: async (
username: string,
password: string,
displayName?: string,
) => {
const res = await authQuery.createLocalUser(
username,
password,
displayName,
);
return res.data;
},
performAuthRefresh: async () => {
const isRefreshSuccess = await refreshToken();
@ -233,7 +261,7 @@ export const auth = {
if (!deviceIdentifier) {
throw new Error(
'Device Identifier not found, you can only signin from the browser you generated the magic link'
'Device Identifier not found, you can only signin from the browser you generated the magic link',
);
}

View file

@ -23,12 +23,28 @@ export default {
}),
signInWithEmailLink: (
token: string | null,
deviceIdentifier: string | null
deviceIdentifier: string | null,
) =>
restApi.post('/auth/verify', {
token,
deviceIdentifier,
}),
setupLocalAdmin: (username: string, password: string) =>
restApi.post('/auth/local/setup-admin', {
username,
password,
}),
signInLocal: (username: string, password: string) =>
restApi.post('/auth/local/signin', {
username,
password,
}),
createLocalUser: (username: string, password: string, displayName?: string) =>
restApi.post('/auth/local/users', {
username,
password,
displayName,
}),
getFirstTimeInfraSetupStatus: () => restApi.get('/site/setup'),
updateFirstTimeInfraSetupStatus: () => restApi.put('/site/setup'),
addOnBoardingConfigs: (config: Record<string, any>) =>

View file

@ -65,6 +65,12 @@ export type ServerConfigs = {
};
};
localAuth: {
name: string;
enabled: boolean;
fields: Record<string, never>;
};
tokenConfigs: {
name: string;
fields: {

View file

@ -11,6 +11,12 @@
@click="showInviteUserModal = true"
:icon="IconAddUser"
/>
<HoppButtonPrimary
v-if="localAuthEnabled"
:label="t('users.create_local_user')"
@click="showCreateLocalUserModal = true"
:icon="IconKeyRound"
/>
<div class="flex">
<HoppButtonSecondary
outline
@ -224,6 +230,54 @@
@hide-modal="inviteSuccessModal = false"
@copy-invite-link="copyInviteLink"
/>
<HoppSmartModal
v-if="showCreateLocalUserModal"
dialog
:title="t('users.create_local_user')"
@close="hideCreateLocalUserModal"
>
<template #body>
<div class="flex flex-col space-y-4">
<HoppSmartInput
v-model="localUserForm.displayName"
:label="t('users.name')"
input-styles="floating-input"
/>
<HoppSmartInput
v-model="localUserForm.username"
:label="t('users.username')"
input-styles="floating-input"
/>
<HoppSmartInput
v-model="localUserForm.password"
:label="t('users.password')"
input-styles="floating-input"
type="password"
/>
<HoppSmartInput
v-model="localUserForm.confirmPassword"
:label="t('users.confirm_password')"
input-styles="floating-input"
type="password"
/>
</div>
</template>
<template #footer>
<div class="flex justify-end space-x-2">
<HoppButtonSecondary
:label="t('users.cancel')"
outline
filled
@click="hideCreateLocalUserModal"
/>
<HoppButtonPrimary
:label="t('users.create_local_user')"
:loading="creatingLocalUser"
@click="createLocalUser"
/>
</div>
</template>
</HoppSmartModal>
<HoppSmartConfirmModal
:show="confirmUsersToAdmin"
:title="
@ -261,11 +315,12 @@
import { useMutation, useQuery } from '@urql/vue';
import { useTimeAgo } from '@vueuse/core';
import { format } from 'date-fns';
import { computed, onUnmounted, ref, watch } from 'vue';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from '~/composables/i18n';
import { useToast } from '~/composables/toast';
import { usePagedQuery } from '~/composables/usePagedQuery';
import { auth } from '~/helpers/auth';
import {
DemoteUsersByAdminDocument,
InviteNewUserDocument,
@ -289,11 +344,17 @@ import IconUserCheck from '~icons/lucide/user-check';
import IconUserMinus from '~icons/lucide/user-minus';
import IconAddUser from '~icons/lucide/user-plus';
import IconX from '~icons/lucide/x';
import IconKeyRound from '~icons/lucide/key-round';
// Get Proper Date Formats
const t = useI18n();
const toast = useToast();
onMounted(async () => {
localAuthEnabled.value =
(await auth.getAllowedAuthProviders())?.includes('LOCAL') ?? false;
});
// Time and Date Helpers
const getCreatedDate = (date: string) => format(new Date(date), 'dd-MMMM-yyyy');
const getCreatedTime = (date: string) => format(new Date(date), 'hh:mm a');
@ -321,14 +382,14 @@ const {
UsersListV2Document,
(x) => x.infra.allUsersV2,
usersPerPage,
{ searchString: '', take: usersPerPage, skip: 0 }
{ searchString: '', take: usersPerPage, skip: 0 },
);
// Selected Rows
const selectedRows = ref<UsersListQuery['infra']['allUsers']>([]);
const areAdminsSelected = computed(() =>
selectedRows.value.some((user) => user.isAdmin)
selectedRows.value.some((user) => user.isAdmin),
);
const areNonAdminsSelected = computed(() => {
@ -396,9 +457,9 @@ const finalUsersList = computed(() =>
searchQuery.value.length > 0
? usersList.value.slice(
(page.value - 1) * usersPerPage,
page.value * usersPerPage
page.value * usersPerPage,
)
: usersList.value
: usersList.value,
);
// Spinner
@ -481,6 +542,69 @@ const copyInviteLink = () => {
// Send Invitation through Email
const showInviteUserModal = ref(false);
const sendInvitation = useMutation(InviteNewUserDocument);
const localAuthEnabled = ref(false);
const showCreateLocalUserModal = ref(false);
const creatingLocalUser = ref(false);
const localUserForm = ref({
displayName: '',
username: '',
password: '',
confirmPassword: '',
});
const hideCreateLocalUserModal = () => {
showCreateLocalUserModal.value = false;
localUserForm.value = {
displayName: '',
username: '',
password: '',
confirmPassword: '',
};
};
const createLocalUser = async () => {
const username = localUserForm.value.username.trim();
const password = localUserForm.value.password;
const displayName = localUserForm.value.displayName.trim();
if (username.length < 3) {
toast.error(t('users.username_min_length'));
return;
}
if (!/^[a-zA-Z0-9_.-]+$/.test(username)) {
toast.error(t('users.username_format'));
return;
}
if (password.length < 12) {
toast.error(t('users.password_min_length'));
return;
}
if (password !== localUserForm.value.confirmPassword) {
toast.error(t('users.password_mismatch'));
return;
}
creatingLocalUser.value = true;
try {
await auth.createLocalUser(username, password, displayName || undefined);
toast.success(t('users.local_user_created'));
hideCreateLocalUserModal();
await refetch({
searchString: searchQuery.value,
take: usersPerPage,
skip: (page.value - 1) * usersPerPage,
});
} catch (err) {
console.error(err);
toast.error(t('users.local_user_create_failed'));
} finally {
creatingLocalUser.value = false;
}
};
const sendInvite = async (email: string) => {
const trimmedEmail = email.trim();
@ -538,13 +662,13 @@ const makeUsersToAdmin = async (id: string | null) => {
toast.error(
areMultipleUsersSelected.value
? t('state.users_to_admin_failure')
: t('state.admin_failure')
: t('state.admin_failure'),
);
} else {
toast.success(
areMultipleUsersSelected.value
? t('state.users_to_admin_success')
: t('state.admin_success')
: t('state.admin_success'),
);
usersList.value = usersList.value.map((user) => ({
...user,
@ -573,7 +697,7 @@ const resetConfirmAdminToUser = () => {
};
const areMultipleUsersSelectedToAdmin = computed(
() => selectedRows.value.length > 1
() => selectedRows.value.length > 1,
);
const makeAdminsToUsers = async (id: string | null) => {
@ -591,13 +715,13 @@ const makeAdminsToUsers = async (id: string | null) => {
toast.error(
areMultipleUsersSelected.value
? t('state.remove_admin_from_users_failure')
: t('state.remove_admin_failure')
: t('state.remove_admin_failure'),
);
} else {
toast.success(
areMultipleUsersSelected.value
? t('state.remove_admin_from_users_success')
: t('state.remove_admin_success')
: t('state.remove_admin_success'),
);
usersList.value = usersList.value.map((user) => ({
...user,
@ -627,7 +751,7 @@ const resetConfirmUserDeletion = () => {
};
const areMultipleUsersSelectedForDeletion = computed(
() => selectedRows.value.length > 1
() => selectedRows.value.length > 1,
);
const deleteUsers = async (id: string | null) => {
@ -649,7 +773,7 @@ const deleteUsers = async (id: string | null) => {
handleUserDeletion(deletedUsers);
usersList.value = usersList.value.filter(
(user) => !deletedUserIDs.includes(user.uid)
(user) => !deletedUserIDs.includes(user.uid),
);
selectedRows.value.splice(0, selectedRows.value.length);