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/ .cursor/
.aider* .aider*
.windsurfrules .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 { model User {
uid String @id @default(cuid()) uid String @id @default(cuid())
username String? @unique
displayName String? displayName String?
email String? @unique email String? @unique
photoURL String? photoURL String?
@ -104,6 +105,7 @@ model User {
createdOn DateTime @default(now()) @db.Timestamptz(3) createdOn DateTime @default(now()) @db.Timestamptz(3)
lastLoggedOn DateTime? @db.Timestamptz(3) lastLoggedOn DateTime? @db.Timestamptz(3)
lastActiveOn DateTime? @db.Timestamptz(3) lastActiveOn DateTime? @db.Timestamptz(3)
localCredential LocalCredential?
providerAccounts Account[] providerAccounts Account[]
invitedUsers InvitedUsers[] invitedUsers InvitedUsers[]
mockServers MockServer[] mockServers MockServer[]
@ -117,6 +119,15 @@ model User {
VerificationToken VerificationToken[] 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 { model Account {
id String @id @default(cuid()) id String @id @default(cuid())
userId String 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 { ConfigService } from '@nestjs/config';
import { throwHTTPErr } from 'src/utils'; import { throwHTTPErr } from 'src/utils';
import { UserLastLoginInterceptor } from 'src/interceptors/user-last-login.interceptor'; 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) @UseGuards(ThrottlerBehindProxyGuard)
@Controller({ path: 'auth', version: '1' }) @Controller({ path: 'auth', version: '1' })
@ -37,6 +43,7 @@ export class AuthController {
constructor( constructor(
private authService: AuthService, private authService: AuthService,
private configService: ConfigService, private configService: ConfigService,
private localAuthService: LocalAuthService,
) {} ) {}
@Get('providers') @Get('providers')
@ -80,6 +87,34 @@ export class AuthController {
authCookieHandler(res, authTokens.right, false, null, this.configService); 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 ** Route to refresh auth tokens with Refresh Token Rotation
* @see https://auth0.com/docs/secure/tokens/refresh-tokens/refresh-token-rotation * @see https://auth0.com/docs/secure/tokens/refresh-tokens/refresh-token-rotation

View file

@ -16,6 +16,7 @@ import {
isInfraConfigTablePopulated, isInfraConfigTablePopulated,
} from 'src/infra-config/helper'; } from 'src/infra-config/helper';
import { InfraConfigModule } from 'src/infra-config/infra-config.module'; import { InfraConfigModule } from 'src/infra-config/infra-config.module';
import { LocalAuthService } from './local-auth.service';
@Module({ @Module({
imports: [ imports: [
@ -29,7 +30,7 @@ import { InfraConfigModule } from 'src/infra-config/infra-config.module';
}), }),
InfraConfigModule, InfraConfigModule,
], ],
providers: [AuthService], providers: [AuthService, LocalAuthService],
controllers: [AuthController], controllers: [AuthController],
}) })
export class AuthModule { 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', GITHUB = 'GITHUB',
MICROSOFT = 'MICROSOFT', MICROSOFT = 'MICROSOFT',
EMAIL = 'EMAIL', 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; 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) * The provided title for the user collection is short (less than 3 characters)
* (UserCollectionService) * (UserCollectionService)

View file

@ -66,6 +66,7 @@ export function getAuthProviderRequiredKeys(
InfraConfigEnum.MICROSOFT_SCOPE, InfraConfigEnum.MICROSOFT_SCOPE,
InfraConfigEnum.MICROSOFT_TENANT, InfraConfigEnum.MICROSOFT_TENANT,
], ],
[AuthProvider.LOCAL]: [],
[AuthProvider.EMAIL]: [AuthProvider.EMAIL]:
env['INFRA'].MAILER_USE_CUSTOM_CONFIGS === 'true' 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 { ServiceStatus } from './helper';
import * as E from 'fp-ts/Either'; import * as E from 'fp-ts/Either';
import { UserService } from 'src/user/user.service'; import { UserService } from 'src/user/user.service';
import { AuthProvider } from 'src/auth/helper';
const mockPrisma = mockDeep<PrismaService>(); const mockPrisma = mockDeep<PrismaService>();
const mockConfigService = mockDeep<ConfigService>(); const mockConfigService = mockDeep<ConfigService>();
@ -295,6 +296,19 @@ describe('InfraConfigService', () => {
}); });
describe('validateEnvValues', () => { 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', () => { describe('MAILER_SMTP_AUTH_TYPE', () => {
it('should accept an empty value (defaults to login at runtime)', () => { it('should accept an empty value (defaults to login at runtime)', () => {
const result = infraConfigService.validateEnvValues([ 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', () => { describe('getOnboardingConfig', () => {
const RECOVERY_TOKEN = 'valid-recovery-token-123'; const RECOVERY_TOKEN = 'valid-recovery-token-123';

View file

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

View file

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

View file

@ -192,6 +192,7 @@
"continue_with_github_enterprise": "Continue with GitHub Enterprise", "continue_with_github_enterprise": "Continue with GitHub Enterprise",
"continue_with_google": "Continue with Google", "continue_with_google": "Continue with Google",
"continue_with_microsoft": "Continue with Microsoft", "continue_with_microsoft": "Continue with Microsoft",
"continue_with_username": "Continue with username",
"email": "Email", "email": "Email",
"logged_out": "Logged out", "logged_out": "Logged out",
"login": "Login", "login": "Login",
@ -200,7 +201,9 @@
"logout": "Logout", "logout": "Logout",
"re_enter_email": "Re-enter email", "re_enter_email": "Re-enter email",
"send_magic_link": "Send a magic link", "send_magic_link": "Send a magic link",
"password": "Password",
"sync": "Sync", "sync": "Sync",
"username": "Username",
"we_sent_magic_link": "We sent you a magic link!", "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." "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')}`" :label="`${t('auth.send_magic_link')}`"
/> />
</form> </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 <div
v-if="!allowedAuthProviders?.length && !additionalLoginItems.length" v-if="!allowedAuthProviders?.length && !additionalLoginItems.length"
@ -116,7 +142,7 @@
label="Privacy Policy" label="Privacy Policy"
/> />
</div> </div>
<div v-if="mode === 'email'"> <div v-if="mode === 'email' || mode === 'local'">
<HoppButtonSecondary <HoppButtonSecondary
:label="t('auth.all_sign_in_options')" :label="t('auth.all_sign_in_options')"
:icon="IconArrowLeft" :icon="IconArrowLeft"
@ -145,7 +171,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Ref, onMounted, ref } from "vue" import { Component, Ref, onMounted, ref } from "vue"
import { useI18n } from "@composables/i18n" import { useI18n } from "@composables/i18n"
import { useStreamSubscriber } from "@composables/stream" import { useStreamSubscriber } from "@composables/stream"
@ -159,6 +185,7 @@ import IconGoogle from "~icons/auth/google"
import IconMicrosoft from "~icons/auth/microsoft" import IconMicrosoft from "~icons/auth/microsoft"
import IconArrowLeft from "~icons/lucide/arrow-left" import IconArrowLeft from "~icons/lucide/arrow-left"
import IconFileText from "~icons/lucide/file-text" import IconFileText from "~icons/lucide/file-text"
import IconKeyRound from "~icons/lucide/key-round"
import { useService } from "dioc/vue" import { useService } from "dioc/vue"
import { LoginItemDef } from "~/platform/auth" import { LoginItemDef } from "~/platform/auth"
@ -178,6 +205,8 @@ const persistenceService = useService(PersistenceService)
const form = { const form = {
email: "", email: "",
username: "",
password: "",
} }
const isLoadingAllowedAuthProviders = ref(true) const isLoadingAllowedAuthProviders = ref(true)
@ -186,6 +215,7 @@ const signingInWithGoogle = ref(false)
const signingInWithGitHub = ref(false) const signingInWithGitHub = ref(false)
const signingInWithMicrosoft = ref(false) const signingInWithMicrosoft = ref(false)
const signingInWithEmail = ref(false) const signingInWithEmail = ref(false)
const signingInWithLocal = ref(false)
const mode = ref("sign-in") const mode = ref("sign-in")
const tosLink = import.meta.env.VITE_APP_TOS_LINK 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 = { type AuthProviderItem = {
id: string id: string
icon: typeof IconGithub icon: Component
label: string label: string
action: (...args: any[]) => any action: (...args: any[]) => any
isLoading: Ref<boolean> 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[] = [ const authProvidersAvailable: AuthProviderItem[] = [
{ {
id: "GITHUB", id: "GITHUB",
@ -395,6 +443,15 @@ const authProvidersAvailable: AuthProviderItem[] = [
}, },
isLoading: signingInWithEmail, isLoading: signingInWithEmail,
}, },
{
id: "LOCAL",
icon: IconKeyRound,
label: t("auth.continue_with_username"),
action: () => {
mode.value = "local"
},
isLoading: signingInWithLocal,
},
] ]
const hideModal = () => { const hideModal = () => {

View file

@ -186,6 +186,17 @@ export type AuthPlatformDef = {
*/ */
signInWithEmail: (email: string) => Promise<void> 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. * 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) * (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>> ) => 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[]>> getAllowedAuthProviders: () => Promise<E.Either<string, string[]>>

View file

@ -9,7 +9,7 @@ import {
} from "@app/api/generated/graphql" } from "@app/api/generated/graphql"
const expectedAllowedProvidersSchema = z.object({ 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 // keeping it as string to avoid backend accidentally breaking frontend when adding new providers
providers: z.array(z.string()), providers: z.array(z.string()),
}) })

View file

@ -429,6 +429,25 @@ export const def: AuthPlatformDef = {
await sendMagicLink(email) 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() { async verifyEmailAddress() {
return return
}, },

View file

@ -9,7 +9,7 @@ import {
} from "@app/api/generated/graphql" } from "@app/api/generated/graphql"
const expectedAllowedProvidersSchema = z.object({ 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 // keeping it as string to avoid backend accidentally breaking frontend when adding new providers
providers: z.array(z.string()), 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) => export const updateUserDisplayName = (updatedDisplayName: string) =>
runMutation< runMutation<
UpdateUserDisplayNameMutation, UpdateUserDisplayNameMutation,

View file

@ -11,7 +11,11 @@ import {
} from "@hoppscotch/common/platform/auth" } from "@hoppscotch/common/platform/auth"
import { PersistenceService } from "@hoppscotch/common/services/persistence" 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" }>() export const authEvents$ = new Subject<AuthEvent | { event: "token_refresh" }>()
const currentUser$ = new BehaviorSubject<HoppUser | null>(null) const currentUser$ = new BehaviorSubject<HoppUser | null>(null)
@ -276,6 +280,11 @@ export const def: AuthPlatformDef = {
await sendMagicLink(email) await sendMagicLink(email)
}, },
async signInWithUsernamePassword(username: string, password: string) {
await signInLocal(username, password)
await setInitialUser()
},
isSignInWithEmailLink(url: string) { isSignInWithEmailLink(url: string) {
const urlObject = new URL(url) const urlObject = new URL(url)
const searchParams = new URLSearchParams(urlObject.search) 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_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.", "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_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." "smtp_required": "SMTP must be enabled and configured to use email based authentication."
}, },
"configs": { "configs": {
@ -36,6 +39,7 @@
"description": "Configure authentication providers for your server", "description": "Configure authentication providers for your server",
"email": "Email", "email": "Email",
"github_enterprise": "Enable Github Enterprise", "github_enterprise": "Enable Github Enterprise",
"local": "Username and Password",
"oauth": "OAuth", "oauth": "OAuth",
"oauth_providers": "OAuth Providers", "oauth_providers": "OAuth Providers",
"provider_not_specified": "Please enable at least one authentication provider", "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.", "description_accordian": "Select the OAuth providers you want to enable and provide the necessary configurations.",
"title": "OAuth" "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": { "onboarding_incomplete": {
"description": "You have not completed the onboarding process. Please set up at least one authentication provider to continue.", "description": "You have not completed the onboarding process. Please set up at least one authentication provider to continue.",
"title": "Onboarding Incomplete" "title": "Onboarding Incomplete"
@ -292,6 +309,7 @@
"continue_email": "Continue with Email", "continue_email": "Continue with Email",
"continue_github": "Continue with Github", "continue_github": "Continue with Github",
"continue_google": "Continue with Google", "continue_google": "Continue with Google",
"continue_local": "Continue with username",
"continue_microsoft": "Continue with Microsoft", "continue_microsoft": "Continue with Microsoft",
"copied_to_clipboard": "Copied to clipboard", "copied_to_clipboard": "Copied to clipboard",
"create_team_failure": "Failed to create workspace!!", "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_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", "login_using_link": "Please ask the user to login using the link below",
"logout": "Logout", "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_sign_in": "Click on the link to sign in.",
"magic_link_success": "We sent a magic link to", "magic_link_success": "We sent a magic link to",
"microsoft_signin_failure": "Failed to login with Microsoft", "microsoft_signin_failure": "Failed to login with Microsoft",
@ -339,6 +358,7 @@
"non_admin_logged_in": "Logged in as non admin user.", "non_admin_logged_in": "Logged in as non admin user.",
"non_admin_login": "You are logged in. But you're not an admin", "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!!", "owner_not_present": "Atleast one owner should be present in the team!!",
"password": "Password",
"privacy_policy": "Privacy Policy", "privacy_policy": "Privacy Policy",
"reenter_email": "Re-enter email", "reenter_email": "Re-enter email",
"remove_admin_failure": "Failed to remove admin status!!", "remove_admin_failure": "Failed to remove admin status!!",
@ -367,12 +387,14 @@
"send_magic_link": "Send magic link", "send_magic_link": "Send magic link",
"setup_failure": "Setup has failed!!", "setup_failure": "Setup has failed!!",
"setup_success": "Setup completed successfully!!", "setup_success": "Setup completed successfully!!",
"sign_in": "Sign in",
"sign_in_agreement": "By signing in, you are agreeing to our", "sign_in_agreement": "By signing in, you are agreeing to our",
"sign_in_options": "All sign in option", "sign_in_options": "All sign in option",
"sign_out": "Sign out", "sign_out": "Sign out",
"something_went_wrong": "Something went wrong", "something_went_wrong": "Something went wrong",
"team_name_too_short": "Workspace name should be atleast 6 characters long!!", "team_name_too_short": "Workspace name should be atleast 6 characters long!!",
"user_already_invited": "Failed to send invite. User is already invited!!", "user_already_invited": "Failed to send invite. User is already invited!!",
"username": "Username",
"user_not_found": "User not found in the infra!!", "user_not_found": "User not found in the infra!!",
"users_to_admin_success": "Selected users are elevated to admin status!!", "users_to_admin_success": "Selected users are elevated to admin status!!",
"users_to_admin_failure": "Failed to elevate selected users to admin status!!" "users_to_admin_failure": "Failed to elevate selected users to admin status!!"
@ -459,6 +481,8 @@
"created_on": "Created On", "created_on": "Created On",
"copy_invite_link": "Copy Invite Link", "copy_invite_link": "Copy Invite Link",
"copy_link": "Copy Link", "copy_link": "Copy Link",
"confirm_password": "Confirm Password",
"create_local_user": "Create Local User",
"date": "Date", "date": "Date",
"delete": "Delete", "delete": "Delete",
"delete_user": "Delete User", "delete_user": "Delete User",
@ -480,6 +504,8 @@
"last_active_on": "Last Active", "last_active_on": "Last Active",
"load_info_error": "Unable to load user info", "load_info_error": "Unable to load user info",
"load_list_error": "Unable to Load Users List", "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", "make_admin": "Make Admin",
"name": "Name", "name": "Name",
"new_user_added": "New User Added", "new_user_added": "New User Added",
@ -490,6 +516,9 @@
"not_found": "User not found", "not_found": "User not found",
"pending_invites": "Pending Invites", "pending_invites": "Pending Invites",
"pending_invites_description": "Manage and track pending user invitations with clear status and actions.", "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_privilege": "Remove Admin Privilege",
"remove_admin_status": "Remove Admin Status", "remove_admin_status": "Remove Admin Status",
"rename": "Rename", "rename": "Rename",
@ -499,6 +528,9 @@
"show_more": "Show more", "show_more": "Show more",
"uid": "UID", "uid": "UID",
"unnamed": "(Unnamed User)", "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!!", "user_not_found": "User not found in the infra!!",
"users": "Users", "users": "Users",
"valid_email": "Please enter a valid email address" "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')" :label="t('state.continue_email')"
@click="mode = 'email'" @click="mode = 'email'"
/> />
<HoppSmartItem
v-if="allowedAuthProviders.includes('LOCAL')"
:icon="IconKeyRound"
:label="t('state.continue_local')"
@click="mode = 'local'"
/>
</div> </div>
<form <form
v-if="mode === 'email' && allowedAuthProviders" v-if="mode === 'email' && allowedAuthProviders"
@ -71,6 +77,32 @@
:label="t('state.send_magic_link')" :label="t('state.send_magic_link')"
/> />
</form> </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 <div
v-if="!allowedAuthProviders?.length" v-if="!allowedAuthProviders?.length"
class="flex flex-col items-center text-center" class="flex flex-col items-center text-center"
@ -139,7 +171,7 @@
:label="t('state.privacy_policy')" :label="t('state.privacy_policy')"
/> />
</div> </div>
<div v-if="mode === 'email'"> <div v-if="mode === 'email' || mode === 'local'">
<HoppButtonSecondary <HoppButtonSecondary
:label="t('state.sign_in_options')" :label="t('state.sign_in_options')"
:icon="IconArrowLeft" :icon="IconArrowLeft"
@ -175,6 +207,7 @@ import IconGoogle from '~icons/auth/google';
import IconMicrosoft from '~icons/auth/microsoft'; import IconMicrosoft from '~icons/auth/microsoft';
import IconArrowLeft from '~icons/lucide/arrow-left'; import IconArrowLeft from '~icons/lucide/arrow-left';
import IconFileText from '~icons/lucide/file-text'; import IconFileText from '~icons/lucide/file-text';
import IconKeyRound from '~icons/lucide/key-round';
const t = useI18n(); const t = useI18n();
const toast = useToast(); const toast = useToast();
@ -184,6 +217,8 @@ const privacyPolicyLink = import.meta.env.VITE_APP_PRIVACY_POLICY_LINK;
const form = ref({ const form = ref({
email: '', email: '',
username: '',
password: '',
}); });
const fetching = ref(false); const fetching = ref(false);
const error = ref(false); const error = ref(false);
@ -191,6 +226,7 @@ const signingInWithGoogle = ref(false);
const signingInWithGitHub = ref(false); const signingInWithGitHub = ref(false);
const signingInWithMicrosoft = ref(false); const signingInWithMicrosoft = ref(false);
const signingInWithEmail = ref(false); const signingInWithEmail = ref(false);
const signingInWithLocal = ref(false);
const mode = ref('sign-in'); const mode = ref('sign-in');
const nonAdminUser = ref(false); const nonAdminUser = ref(false);
@ -265,6 +301,26 @@ const signInWithEmail = async () => {
signingInWithEmail.value = false; 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 () => { const getAllowedAuthProviders = async () => {
fetching.value = true; fetching.value = true;
try { 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" 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>
<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> </div>
<HoppButtonPrimary <HoppButtonPrimary
@ -115,6 +126,41 @@
v-model:enabledConfigs="enabledConfigs" v-model:enabledConfigs="enabledConfigs"
/> />
</UiAccordion> </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>
<div class="flex items-center space-x-4 mt-6"> <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 IconLucideArrowRight from '~icons/lucide/arrow-right';
import IconLucideArrowLeft from '~icons/lucide/arrow-left'; import IconLucideArrowLeft from '~icons/lucide/arrow-left';
import IconLucideSave from '~icons/lucide/save'; import IconLucideSave from '~icons/lucide/save';
import IconLucideKeyRound from '~icons/lucide/key-round';
import AuthProviderCard from './AuthProviderCard.vue'; import AuthProviderCard from './AuthProviderCard.vue';
const t = useI18n(); const t = useI18n();
@ -176,7 +223,7 @@ const emit = defineEmits<{
): void; ): void;
}>(); }>();
type SelectedOption = 'OAUTH' | 'SMTP'; type SelectedOption = 'OAUTH' | 'SMTP' | 'LOCAL';
const authConfigStep = ref(1); const authConfigStep = ref(1);
const selectedOptions = ref<SelectedOption[]>([]); const selectedOptions = ref<SelectedOption[]>([]);
@ -184,6 +231,7 @@ const selectedOptions = ref<SelectedOption[]>([]);
const { const {
currentConfigs, currentConfigs,
enabledConfigs, enabledConfigs,
localAdminCredentials,
isProvidersLoading, isProvidersLoading,
submittingConfigs, submittingConfigs,
onBoardingSummary, onBoardingSummary,
@ -240,6 +288,7 @@ const isSmtpEnabled = computed(
enabledConfigs.value.includes('MAILER') || enabledConfigs.value.includes('MAILER') ||
enabledConfigs.value.includes('EMAIL'), enabledConfigs.value.includes('EMAIL'),
); );
const isLocalEnabled = computed(() => enabledConfigs.value.includes('LOCAL'));
const updateOAuthEnabled = () => { const updateOAuthEnabled = () => {
isOAuthEnabled.value = OAuthProviders.some((provider) => isOAuthEnabled.value = OAuthProviders.some((provider) =>
@ -263,6 +312,10 @@ const toggleSelectedOption = (option: SelectedOption) => {
toggleConfig('EMAIL'); toggleConfig('EMAIL');
toggleSmtpConfig(); toggleSmtpConfig();
} }
} else if (option === 'LOCAL') {
if (willBeSelected !== isLocalEnabled.value) {
toggleConfig('LOCAL');
}
} else if (willBeSelected !== isOAuthEnabled.value) { } else if (willBeSelected !== isOAuthEnabled.value) {
toggleConfig(option); toggleConfig(option);
} }
@ -277,7 +330,10 @@ const proceedToConfig = () => {
}; };
const submitConfigs = async () => { const submitConfigs = async () => {
const res = await addOnBoardingConfigs(); const res = await addOnBoardingConfigs({
setupLocalAdmin:
props.isFirstTimeSetup === true && enabledConfigs.value.includes('LOCAL'),
});
if (res?.token) { if (res?.token) {
emit('complete-onboarding', { emit('complete-onboarding', {
submittingConfigs: submittingConfigs.value, submittingConfigs: submittingConfigs.value,

View file

@ -62,6 +62,36 @@
</div> </div>
</HoppSmartTab> </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')"> <HoppSmartTab id="token" :label="t('configs.auth_providers.token.title')">
<div class="pb-8 px-4"> <div class="pb-8 px-4">
<SettingsAuthToken v-model:config="workingConfigs" /> <SettingsAuthToken v-model:config="workingConfigs" />
@ -88,14 +118,14 @@ const emit = defineEmits<{
}>(); }>();
// Auth Sub Tabs // 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 selectedAuthSubTab = ref<AuthSubTabs>('auth-providers');
const workingConfigs = useVModel(props, 'config', emit); 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. // Check if SMTP is activated but not saved yet. Used to track if SMTP was enabled after the last save.
const isSMTPActivated = computed( const isSMTPActivated = computed(
() => workingConfigs.value?.mailConfigs.enabled ?? false () => workingConfigs.value?.mailConfigs.enabled ?? false,
); );
// Check if Email authentication is enabled // 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 // Check if SMTP is enabled on mount
const isSMTPEnabled = ref(false); const isSMTPEnabled = ref(false);

View file

@ -38,7 +38,7 @@ import { useClientHandler } from './useClientHandler';
const COOKIE_NAME_REGEX = /^[A-Za-z0-9_-]+$/; const COOKIE_NAME_REGEX = /^[A-Za-z0-9_-]+$/;
const OPTIONAL_TOKEN_FIELD_KEYS = new Set( 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 /** Composable that handles all operations related to server configurations
@ -58,10 +58,10 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
InfraConfigsDocument, InfraConfigsDocument,
{ {
configNames: ALL_CONFIGS.flat().map( configNames: ALL_CONFIGS.flat().map(
({ name }) => name ({ name }) => name,
) as InfraConfigEnum[], ) as InfraConfigEnum[],
}, },
(x) => x.infraConfigs (x) => x.infraConfigs,
); );
// Fetching allowed auth providers // Fetching allowed auth providers
@ -73,7 +73,7 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
} = useClientHandler( } = useClientHandler(
AllowedAuthProvidersDocument, AllowedAuthProvidersDocument,
{}, {},
(x) => x.allowedAuthProviders (x) => x.allowedAuthProviders,
); );
// Current and working configs // Current and working configs
@ -133,7 +133,7 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
mailer_smtp_port: getFieldValue(InfraConfigEnum.MailerSmtpPort), mailer_smtp_port: getFieldValue(InfraConfigEnum.MailerSmtpPort),
mailer_smtp_user: getFieldValue(InfraConfigEnum.MailerSmtpUser), mailer_smtp_user: getFieldValue(InfraConfigEnum.MailerSmtpUser),
mailer_smtp_password: getFieldValue( mailer_smtp_password: getFieldValue(
InfraConfigEnum.MailerSmtpPassword InfraConfigEnum.MailerSmtpPassword,
), ),
mailer_smtp_secure: mailer_smtp_secure:
getFieldValue(InfraConfigEnum.MailerSmtpSecure) === 'true', getFieldValue(InfraConfigEnum.MailerSmtpSecure) === 'true',
@ -147,37 +147,42 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
mailer_smtp_auth_type: mailer_smtp_auth_type:
getFieldValue(InfraConfigEnum.MailerSmtpAuthType) || 'login', getFieldValue(InfraConfigEnum.MailerSmtpAuthType) || 'login',
mailer_smtp_oauth2_user: getFieldValue( mailer_smtp_oauth2_user: getFieldValue(
InfraConfigEnum.MailerSmtpOauth2User InfraConfigEnum.MailerSmtpOauth2User,
), ),
mailer_smtp_oauth2_client_id: getFieldValue( mailer_smtp_oauth2_client_id: getFieldValue(
InfraConfigEnum.MailerSmtpOauth2ClientId InfraConfigEnum.MailerSmtpOauth2ClientId,
), ),
mailer_smtp_oauth2_client_secret: getFieldValue( mailer_smtp_oauth2_client_secret: getFieldValue(
InfraConfigEnum.MailerSmtpOauth2ClientSecret InfraConfigEnum.MailerSmtpOauth2ClientSecret,
), ),
mailer_smtp_oauth2_refresh_token: getFieldValue( mailer_smtp_oauth2_refresh_token: getFieldValue(
InfraConfigEnum.MailerSmtpOauth2RefreshToken InfraConfigEnum.MailerSmtpOauth2RefreshToken,
), ),
mailer_smtp_oauth2_access_url: getFieldValue( mailer_smtp_oauth2_access_url: getFieldValue(
InfraConfigEnum.MailerSmtpOauth2AccessUrl InfraConfigEnum.MailerSmtpOauth2AccessUrl,
), ),
}, },
}, },
localAuth: {
name: 'local',
enabled: allowedAuthProviders.value.includes(AuthProvider.Local),
fields: {},
},
tokenConfigs: { tokenConfigs: {
name: 'token', name: 'token',
fields: { fields: {
jwt_secret: getFieldValue(InfraConfigEnum.JwtSecret), jwt_secret: getFieldValue(InfraConfigEnum.JwtSecret),
token_salt_complexity: getFieldValue( token_salt_complexity: getFieldValue(
InfraConfigEnum.TokenSaltComplexity InfraConfigEnum.TokenSaltComplexity,
), ),
magic_link_token_validity: getFieldValue( magic_link_token_validity: getFieldValue(
InfraConfigEnum.MagicLinkTokenValidity InfraConfigEnum.MagicLinkTokenValidity,
), ),
refresh_token_validity: getFieldValue( refresh_token_validity: getFieldValue(
InfraConfigEnum.RefreshTokenValidity InfraConfigEnum.RefreshTokenValidity,
), ),
access_token_validity: getFieldValue( access_token_validity: getFieldValue(
InfraConfigEnum.AccessTokenValidity InfraConfigEnum.AccessTokenValidity,
), ),
session_secret: getFieldValue(InfraConfigEnum.SessionSecret), session_secret: getFieldValue(InfraConfigEnum.SessionSecret),
session_cookie_name: getFieldValue(InfraConfigEnum.SessionCookieName), session_cookie_name: getFieldValue(InfraConfigEnum.SessionCookieName),
@ -186,7 +191,7 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
dataSharingConfigs: { dataSharingConfigs: {
name: 'data_sharing', name: 'data_sharing',
enabled: !!infraConfigs.value.find( enabled: !!infraConfigs.value.find(
(x) => x.name === 'ALLOW_ANALYTICS_COLLECTION' && x.value === 'true' (x) => x.name === 'ALLOW_ANALYTICS_COLLECTION' && x.value === 'true',
), ),
}, },
historyConfig: { historyConfig: {
@ -194,7 +199,7 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
enabled: !!infraConfigs.value.find( enabled: !!infraConfigs.value.find(
(config) => (config) =>
config.name === 'USER_HISTORY_STORE_ENABLED' && config.name === 'USER_HISTORY_STORE_ENABLED' &&
config.value === 'ENABLE' config.value === 'ENABLE',
), ),
}, },
rateLimitConfigs: { rateLimitConfigs: {
@ -208,7 +213,7 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
name: 'mock_server', name: 'mock_server',
fields: { fields: {
mock_server_wildcard_domain: getFieldValue( mock_server_wildcard_domain: getFieldValue(
InfraConfigEnum.MockServerWildcardDomain InfraConfigEnum.MockServerWildcardDomain,
), ),
}, },
}, },
@ -316,8 +321,7 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
return ( return (
section.enabled && section.enabled &&
Object.entries(otherFields).some( Object.entries(otherFields).some(
([key, value]) => ([key, value]) => isFieldEmpty(value) && !excludeKeys.includes(key),
isFieldEmpty(value) && !excludeKeys.includes(key)
) )
); );
} }
@ -327,7 +331,7 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
if (section.name === 'token') { if (section.name === 'token') {
return Object.entries(section.fields).some( return Object.entries(section.fields).some(
([key, value]) => ([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) =>
x.key === key && x.key === key &&
key !== 'mailer_smtp_url' && key !== 'mailer_smtp_url' &&
key !== 'mailer_smtp_enabled' key !== 'mailer_smtp_enabled',
); );
} else } else
return MAIL_CONFIGS.some( 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 // Extract the custom mail config fields
const customMailConfigFields = Object.fromEntries( const customMailConfigFields = Object.fromEntries(
Object.entries(updatedConfigs?.mailConfigs.fields ?? {}).filter(([key]) => 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 // 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>( const executeMutation = async <T, V>(
mutation: UseMutationResponse<T>, mutation: UseMutationResponse<T>,
variables: AnyVariables = undefined, variables: AnyVariables = undefined,
errorMessage: string errorMessage: string,
): Promise<boolean> => { ): Promise<boolean> => {
const result = await mutation.executeMutation(variables); const result = await mutation.executeMutation(variables);
@ -460,7 +464,7 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
// Updating the auth provider configurations // Updating the auth provider configurations
const updateAuthProvider = ( const updateAuthProvider = (
updateProviderStatus: UseMutationResponse<EnableAndDisableSsoMutation> updateProviderStatus: UseMutationResponse<EnableAndDisableSsoMutation>,
) => { ) => {
const updatedAllowedAuthProviders: EnableAndDisableSsoArgs[] = [ const updatedAllowedAuthProviders: EnableAndDisableSsoArgs[] = [
{ {
@ -487,6 +491,12 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
? ServiceStatus.Enable ? ServiceStatus.Enable
: ServiceStatus.Disable, : ServiceStatus.Disable,
}, },
{
provider: AuthProvider.Local,
status: updatedConfigs?.localAuth.enabled
? ServiceStatus.Enable
: ServiceStatus.Disable,
},
]; ];
return executeMutation( return executeMutation(
@ -494,13 +504,13 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
{ {
providerInfo: updatedAllowedAuthProviders, providerInfo: updatedAllowedAuthProviders,
}, },
'configs.auth_providers.update_failure' 'configs.auth_providers.update_failure',
); );
}; };
// Updating the infra configurations // Updating the infra configurations
const updateInfraConfigs = ( const updateInfraConfigs = (
updateInfraConfigsMutation: UseMutationResponse<UpdateInfraConfigsMutation> updateInfraConfigsMutation: UseMutationResponse<UpdateInfraConfigsMutation>,
) => { ) => {
const infraConfigs: InfraConfigArgs[] = updatedConfigs const infraConfigs: InfraConfigArgs[] = updatedConfigs
? transformInfraConfigs() ? transformInfraConfigs()
@ -511,23 +521,23 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
{ {
infraConfigs, infraConfigs,
}, },
'configs.update_failure' 'configs.update_failure',
); );
}; };
// Resetting the infra configurations // Resetting the infra configurations
const resetInfraConfigs = ( const resetInfraConfigs = (
resetInfraConfigsMutation: UseMutationResponse<ResetInfraConfigsMutation> resetInfraConfigsMutation: UseMutationResponse<ResetInfraConfigsMutation>,
) => ) =>
executeMutation( executeMutation(
resetInfraConfigsMutation, resetInfraConfigsMutation,
undefined, undefined,
'configs.reset.failure' 'configs.reset.failure',
); );
// Toggle Data Sharing // Toggle Data Sharing
const updateDataSharingConfigs = ( const updateDataSharingConfigs = (
toggleDataSharingMutation: UseMutationResponse<ToggleAnalyticsCollectionMutation> toggleDataSharingMutation: UseMutationResponse<ToggleAnalyticsCollectionMutation>,
) => ) =>
executeMutation( executeMutation(
toggleDataSharingMutation, toggleDataSharingMutation,
@ -536,12 +546,12 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
? ServiceStatus.Enable ? ServiceStatus.Enable
: ServiceStatus.Disable, : ServiceStatus.Disable,
}, },
'configs.data_sharing.update_failure' 'configs.data_sharing.update_failure',
); );
// Toggle SMTP // Toggle SMTP
const toggleSMTPConfigs = ( const toggleSMTPConfigs = (
toggleSMTP: UseMutationResponse<ToggleSmtpMutation> toggleSMTP: UseMutationResponse<ToggleSmtpMutation>,
) => ) =>
executeMutation( executeMutation(
toggleSMTP, toggleSMTP,
@ -550,12 +560,12 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
? ServiceStatus.Enable ? ServiceStatus.Enable
: ServiceStatus.Disable, : ServiceStatus.Disable,
}, },
'configs.mail_configs.toggle_failure' 'configs.mail_configs.toggle_failure',
); );
// Toggle User History Store // Toggle User History Store
const toggleUserHistoryStore = ( const toggleUserHistoryStore = (
toggleUserHistoryStore: UseMutationResponse<ToggleUserHistoryStoreMutation> toggleUserHistoryStore: UseMutationResponse<ToggleUserHistoryStoreMutation>,
) => ) =>
executeMutation( executeMutation(
toggleUserHistoryStore, toggleUserHistoryStore,
@ -564,11 +574,11 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
? ServiceStatus.Enable ? ServiceStatus.Enable
: ServiceStatus.Disable, : ServiceStatus.Disable,
}, },
'configs.user_history_store.toggle_failure' 'configs.user_history_store.toggle_failure',
); );
const updateRateLimitConfigs = ( const updateRateLimitConfigs = (
updateRateLimitMutation: UseMutationResponse<UpdateInfraConfigsMutation> updateRateLimitMutation: UseMutationResponse<UpdateInfraConfigsMutation>,
) => { ) => {
if (!updatedConfigs?.rateLimitConfigs) { if (!updatedConfigs?.rateLimitConfigs) {
toast.error(t('configs.rate_limit.input_validation_error')); toast.error(t('configs.rate_limit.input_validation_error'));
@ -576,10 +586,10 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
} }
const rateLimitTtl = String( const rateLimitTtl = String(
updatedConfigs?.rateLimitConfigs.fields.rate_limit_ttl updatedConfigs?.rateLimitConfigs.fields.rate_limit_ttl,
); );
const rateLimitMax = String( const rateLimitMax = String(
updatedConfigs?.rateLimitConfigs.fields.rate_limit_max updatedConfigs?.rateLimitConfigs.fields.rate_limit_max,
); );
if (isFieldEmpty(rateLimitTtl) || isFieldEmpty(rateLimitMax)) { if (isFieldEmpty(rateLimitTtl) || isFieldEmpty(rateLimitMax)) {
@ -603,12 +613,12 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
{ {
infraConfigs: rateLimitConfigs, infraConfigs: rateLimitConfigs,
}, },
'configs.rate_limit.update_failure' 'configs.rate_limit.update_failure',
); );
}; };
const updateAuthTokenConfigs = ( const updateAuthTokenConfigs = (
updateAuthTokenMutation: UseMutationResponse<UpdateInfraConfigsMutation> updateAuthTokenMutation: UseMutationResponse<UpdateInfraConfigsMutation>,
) => { ) => {
if (!updatedConfigs?.tokenConfigs) { if (!updatedConfigs?.tokenConfigs) {
toast.error(t('configs.auth_providers.token.update_failure')); 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 jwtSecret = String(updatedConfigs?.tokenConfigs.fields.jwt_secret);
const tokenSaltComplexity = String( const tokenSaltComplexity = String(
updatedConfigs?.tokenConfigs.fields.token_salt_complexity updatedConfigs?.tokenConfigs.fields.token_salt_complexity,
); );
const magicLinkTokenValidity = String( const magicLinkTokenValidity = String(
updatedConfigs?.tokenConfigs.fields.magic_link_token_validity updatedConfigs?.tokenConfigs.fields.magic_link_token_validity,
); );
const refreshTokenValidity = String( const refreshTokenValidity = String(
updatedConfigs?.tokenConfigs.fields.refresh_token_validity updatedConfigs?.tokenConfigs.fields.refresh_token_validity,
); );
const accessTokenValidity = String( const accessTokenValidity = String(
updatedConfigs?.tokenConfigs.fields.access_token_validity updatedConfigs?.tokenConfigs.fields.access_token_validity,
); );
const sessionSecret = String( const sessionSecret = String(
updatedConfigs?.tokenConfigs.fields.session_secret updatedConfigs?.tokenConfigs.fields.session_secret,
); );
const sessionCookieName = String( 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 // Validate cookie name: allow empty (falls back to default), else enforce pattern
if (sessionCookieName && !COOKIE_NAME_REGEX.test(sessionCookieName)) { 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; return false;
} }
if ( if (
@ -687,7 +699,7 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
{ {
infraConfigs: authTokenConfigs, 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'; import { makeReadableKey } from '~/helpers/utils/readableKey';
export type OAuthProvider = 'GOOGLE' | 'GITHUB' | 'MICROSOFT'; 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 // common OAuth keys used across providers
type OAuthKeys = 'CLIENT_ID' | 'CLIENT_SECRET' | 'CALLBACK_URL' | 'SCOPE'; type OAuthKeys = 'CLIENT_ID' | 'CLIENT_SECRET' | 'CALLBACK_URL' | 'SCOPE';
@ -124,6 +135,11 @@ export function useOnboardingConfigHandler() {
const enabledConfigs = ref<EnabledConfig[]>([]); const enabledConfigs = ref<EnabledConfig[]>([]);
const isProvidersLoading = ref(false); const isProvidersLoading = ref(false);
const submittingConfigs = ref(false); const submittingConfigs = ref(false);
const localAdminCredentials = ref<LocalAdminCredentials>({
username: '',
password: '',
confirmPassword: '',
});
const onBoardingSummary = ref<OnBoardingSummary>({ const onBoardingSummary = ref<OnBoardingSummary>({
type: 'success', 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. * Adds the onboarding configs to the backend.
* It validates the configs, prepares the payload, * 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. * We set the token in localStorage for re-fetching configs later.
* @returns The token for re-fetching configs or undefined if failed * @returns The token for re-fetching configs or undefined if failed
*/ */
const addOnBoardingConfigs = async () => { const addOnBoardingConfigs = async (options?: {
setupLocalAdmin?: boolean;
}) => {
submittingConfigs.value = true; submittingConfigs.value = true;
const payload = { const payload = {
...currentConfigs.value.oAuthProviders.GOOGLE, ...currentConfigs.value.oAuthProviders.GOOGLE,
@ -350,25 +398,46 @@ export function useOnboardingConfigHandler() {
...currentConfigs.value.mailerConfigs, ...currentConfigs.value.mailerConfigs,
}; };
const filteredEnabledConfigs = enabledConfigs.value.filter(
(config) => config !== 'OAUTH' && config !== 'MAILER',
);
const hasLocalAuth = filteredEnabledConfigs.includes('LOCAL');
const validated = validateConfigs(payload); 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')); toast.error(t('onboarding.add_atleast_one_auth_provider'));
submittingConfigs.value = false; submittingConfigs.value = false;
return; return;
} }
const filteredEnabledConfigs = enabledConfigs.value.filter( if (options?.setupLocalAdmin && !validateLocalAdminCredentials()) {
(config) => config !== 'OAUTH' && config !== 'MAILER', submittingConfigs.value = false;
); return;
}
const configWithAuth = { const configWithAuth = {
...validated, ...(validated ?? {}),
[InfraConfigEnum.ViteAllowedAuthProviders]: [InfraConfigEnum.ViteAllowedAuthProviders]:
filteredEnabledConfigs.join(','), filteredEnabledConfigs.join(','),
}; };
try { 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); const res = await auth.addOnBoardingConfigs(configWithAuth);
if (res?.token) { if (res?.token) {
setLocalConfig('access_token', res.token); setLocalConfig('access_token', res.token);
@ -453,6 +522,7 @@ export function useOnboardingConfigHandler() {
return { return {
currentConfigs, currentConfigs,
enabledConfigs, enabledConfigs,
localAdminCredentials,
isProvidersLoading, isProvidersLoading,
onBoardingSummary, onBoardingSummary,
submittingConfigs, submittingConfigs,

View file

@ -212,6 +212,34 @@ export const auth = {
return authQuery.signInWithEmailLink(token, deviceIdentifier); 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 () => { performAuthRefresh: async () => {
const isRefreshSuccess = await refreshToken(); const isRefreshSuccess = await refreshToken();
@ -233,7 +261,7 @@ export const auth = {
if (!deviceIdentifier) { if (!deviceIdentifier) {
throw new Error( 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: ( signInWithEmailLink: (
token: string | null, token: string | null,
deviceIdentifier: string | null deviceIdentifier: string | null,
) => ) =>
restApi.post('/auth/verify', { restApi.post('/auth/verify', {
token, token,
deviceIdentifier, 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'), getFirstTimeInfraSetupStatus: () => restApi.get('/site/setup'),
updateFirstTimeInfraSetupStatus: () => restApi.put('/site/setup'), updateFirstTimeInfraSetupStatus: () => restApi.put('/site/setup'),
addOnBoardingConfigs: (config: Record<string, any>) => addOnBoardingConfigs: (config: Record<string, any>) =>

View file

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

View file

@ -11,6 +11,12 @@
@click="showInviteUserModal = true" @click="showInviteUserModal = true"
:icon="IconAddUser" :icon="IconAddUser"
/> />
<HoppButtonPrimary
v-if="localAuthEnabled"
:label="t('users.create_local_user')"
@click="showCreateLocalUserModal = true"
:icon="IconKeyRound"
/>
<div class="flex"> <div class="flex">
<HoppButtonSecondary <HoppButtonSecondary
outline outline
@ -224,6 +230,54 @@
@hide-modal="inviteSuccessModal = false" @hide-modal="inviteSuccessModal = false"
@copy-invite-link="copyInviteLink" @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 <HoppSmartConfirmModal
:show="confirmUsersToAdmin" :show="confirmUsersToAdmin"
:title=" :title="
@ -261,11 +315,12 @@
import { useMutation, useQuery } from '@urql/vue'; import { useMutation, useQuery } from '@urql/vue';
import { useTimeAgo } from '@vueuse/core'; import { useTimeAgo } from '@vueuse/core';
import { format } from 'date-fns'; 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 { useRouter } from 'vue-router';
import { useI18n } from '~/composables/i18n'; import { useI18n } from '~/composables/i18n';
import { useToast } from '~/composables/toast'; import { useToast } from '~/composables/toast';
import { usePagedQuery } from '~/composables/usePagedQuery'; import { usePagedQuery } from '~/composables/usePagedQuery';
import { auth } from '~/helpers/auth';
import { import {
DemoteUsersByAdminDocument, DemoteUsersByAdminDocument,
InviteNewUserDocument, InviteNewUserDocument,
@ -289,11 +344,17 @@ import IconUserCheck from '~icons/lucide/user-check';
import IconUserMinus from '~icons/lucide/user-minus'; import IconUserMinus from '~icons/lucide/user-minus';
import IconAddUser from '~icons/lucide/user-plus'; import IconAddUser from '~icons/lucide/user-plus';
import IconX from '~icons/lucide/x'; import IconX from '~icons/lucide/x';
import IconKeyRound from '~icons/lucide/key-round';
// Get Proper Date Formats // Get Proper Date Formats
const t = useI18n(); const t = useI18n();
const toast = useToast(); const toast = useToast();
onMounted(async () => {
localAuthEnabled.value =
(await auth.getAllowedAuthProviders())?.includes('LOCAL') ?? false;
});
// Time and Date Helpers // Time and Date Helpers
const getCreatedDate = (date: string) => format(new Date(date), 'dd-MMMM-yyyy'); const getCreatedDate = (date: string) => format(new Date(date), 'dd-MMMM-yyyy');
const getCreatedTime = (date: string) => format(new Date(date), 'hh:mm a'); const getCreatedTime = (date: string) => format(new Date(date), 'hh:mm a');
@ -321,14 +382,14 @@ const {
UsersListV2Document, UsersListV2Document,
(x) => x.infra.allUsersV2, (x) => x.infra.allUsersV2,
usersPerPage, usersPerPage,
{ searchString: '', take: usersPerPage, skip: 0 } { searchString: '', take: usersPerPage, skip: 0 },
); );
// Selected Rows // Selected Rows
const selectedRows = ref<UsersListQuery['infra']['allUsers']>([]); const selectedRows = ref<UsersListQuery['infra']['allUsers']>([]);
const areAdminsSelected = computed(() => const areAdminsSelected = computed(() =>
selectedRows.value.some((user) => user.isAdmin) selectedRows.value.some((user) => user.isAdmin),
); );
const areNonAdminsSelected = computed(() => { const areNonAdminsSelected = computed(() => {
@ -396,9 +457,9 @@ const finalUsersList = computed(() =>
searchQuery.value.length > 0 searchQuery.value.length > 0
? usersList.value.slice( ? usersList.value.slice(
(page.value - 1) * usersPerPage, (page.value - 1) * usersPerPage,
page.value * usersPerPage page.value * usersPerPage,
) )
: usersList.value : usersList.value,
); );
// Spinner // Spinner
@ -481,6 +542,69 @@ const copyInviteLink = () => {
// Send Invitation through Email // Send Invitation through Email
const showInviteUserModal = ref(false); const showInviteUserModal = ref(false);
const sendInvitation = useMutation(InviteNewUserDocument); 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 sendInvite = async (email: string) => {
const trimmedEmail = email.trim(); const trimmedEmail = email.trim();
@ -538,13 +662,13 @@ const makeUsersToAdmin = async (id: string | null) => {
toast.error( toast.error(
areMultipleUsersSelected.value areMultipleUsersSelected.value
? t('state.users_to_admin_failure') ? t('state.users_to_admin_failure')
: t('state.admin_failure') : t('state.admin_failure'),
); );
} else { } else {
toast.success( toast.success(
areMultipleUsersSelected.value areMultipleUsersSelected.value
? t('state.users_to_admin_success') ? t('state.users_to_admin_success')
: t('state.admin_success') : t('state.admin_success'),
); );
usersList.value = usersList.value.map((user) => ({ usersList.value = usersList.value.map((user) => ({
...user, ...user,
@ -573,7 +697,7 @@ const resetConfirmAdminToUser = () => {
}; };
const areMultipleUsersSelectedToAdmin = computed( const areMultipleUsersSelectedToAdmin = computed(
() => selectedRows.value.length > 1 () => selectedRows.value.length > 1,
); );
const makeAdminsToUsers = async (id: string | null) => { const makeAdminsToUsers = async (id: string | null) => {
@ -591,13 +715,13 @@ const makeAdminsToUsers = async (id: string | null) => {
toast.error( toast.error(
areMultipleUsersSelected.value areMultipleUsersSelected.value
? t('state.remove_admin_from_users_failure') ? t('state.remove_admin_from_users_failure')
: t('state.remove_admin_failure') : t('state.remove_admin_failure'),
); );
} else { } else {
toast.success( toast.success(
areMultipleUsersSelected.value areMultipleUsersSelected.value
? t('state.remove_admin_from_users_success') ? t('state.remove_admin_from_users_success')
: t('state.remove_admin_success') : t('state.remove_admin_success'),
); );
usersList.value = usersList.value.map((user) => ({ usersList.value = usersList.value.map((user) => ({
...user, ...user,
@ -627,7 +751,7 @@ const resetConfirmUserDeletion = () => {
}; };
const areMultipleUsersSelectedForDeletion = computed( const areMultipleUsersSelectedForDeletion = computed(
() => selectedRows.value.length > 1 () => selectedRows.value.length > 1,
); );
const deleteUsers = async (id: string | null) => { const deleteUsers = async (id: string | null) => {
@ -649,7 +773,7 @@ const deleteUsers = async (id: string | null) => {
handleUserDeletion(deletedUsers); handleUserDeletion(deletedUsers);
usersList.value = usersList.value.filter( usersList.value = usersList.value.filter(
(user) => !deletedUserIDs.includes(user.uid) (user) => !deletedUserIDs.includes(user.uid),
); );
selectedRows.value.splice(0, selectedRows.value.length); selectedRows.value.splice(0, selectedRows.value.length);