Compare commits
10 commits
5606a79af1
...
b1e4b648ee
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1e4b648ee | ||
|
|
d4bbde7deb | ||
|
|
892f69817b | ||
|
|
b17375eb16 | ||
|
|
4c30592ae4 | ||
|
|
60cf156230 | ||
|
|
0ec0ae442a | ||
|
|
c8b7a172a4 | ||
|
|
7b4cfb4103 | ||
|
|
4b7c61d180 |
33 changed files with 1536 additions and 86 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -188,3 +188,4 @@ devenv.local.nix
|
|||
.cursor/
|
||||
.aider*
|
||||
.windsurfrules
|
||||
docs/superpowers/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
144
packages/hoppscotch-backend/src/auth/auth.controller.spec.ts
Normal file
144
packages/hoppscotch-backend/src/auth/auth.controller.spec.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
29
packages/hoppscotch-backend/src/auth/dto/local-auth.dto.ts
Normal file
29
packages/hoppscotch-backend/src/auth/dto/local-auth.dto.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -27,6 +27,7 @@ export enum AuthProvider {
|
|||
GITHUB = 'GITHUB',
|
||||
MICROSOFT = 'MICROSOFT',
|
||||
EMAIL = 'EMAIL',
|
||||
LOCAL = 'LOCAL',
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
318
packages/hoppscotch-backend/src/auth/local-auth.service.spec.ts
Normal file
318
packages/hoppscotch-backend/src/auth/local-auth.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
227
packages/hoppscotch-backend/src/auth/local-auth.service.ts
Normal file
227
packages/hoppscotch-backend/src/auth/local-auth.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ export function getAuthProviderRequiredKeys(
|
|||
InfraConfigEnum.MICROSOFT_SCOPE,
|
||||
InfraConfigEnum.MICROSOFT_TENANT,
|
||||
],
|
||||
[AuthProvider.LOCAL]: [],
|
||||
[AuthProvider.EMAIL]:
|
||||
env['INFRA'].MAILER_USE_CUSTOM_CONFIGS === 'true'
|
||||
? [
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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[]>>
|
||||
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 l’authentification 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": "L’identifiant ne peut contenir que des lettres, chiffres, points, tirets et underscores.",
|
||||
"username_min_length": "L’identifiant 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 l’utilisateur 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": "L’identifiant ne peut contenir que des lettres, chiffres, points, tirets et underscores.",
|
||||
"username_min_length": "L’identifiant doit contenir au moins 3 caractères."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>) =>
|
||||
|
|
|
|||
|
|
@ -65,6 +65,12 @@ export type ServerConfigs = {
|
|||
};
|
||||
};
|
||||
|
||||
localAuth: {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
fields: Record<string, never>;
|
||||
};
|
||||
|
||||
tokenConfigs: {
|
||||
name: string;
|
||||
fields: {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue