feat: Creating the first user

This commit is contained in:
Aleksi Lassila
2024-06-02 02:59:38 +03:00
parent 052ea44548
commit db21aef3f3
12 changed files with 141 additions and 44 deletions

View File

@@ -8,11 +8,7 @@ import {
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { SignInDto } from '../user/user.dto';
import {
ApiOkResponse,
ApiProperty,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { ApiOkResponse, ApiProperty } from '@nestjs/swagger';
import { ApiException } from '@nanogiants/nestjs-swagger-api-exception-decorator';
export class SignInResponse {

View File

@@ -18,6 +18,12 @@ export const GetUser = createParamDecorator(
},
);
function extractTokenFromHeader(request: Request): string | undefined {
const [type, token] =
(request.headers as any).authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
@Injectable()
export class AuthGuard implements CanActivate {
constructor(
@@ -27,7 +33,7 @@ export class AuthGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
const token = extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException();
}
@@ -46,10 +52,34 @@ export class AuthGuard implements CanActivate {
}
return true;
}
}
private extractTokenFromHeader(request: Request): string | undefined {
const [type, token] =
(request.headers as any).authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
@Injectable()
export class OptionalAuthGuard implements CanActivate {
constructor(
private jwtService: JwtService,
private userService: UserService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = extractTokenFromHeader(request);
if (!token) {
return true;
}
try {
const payload: AccessTokenPayload = await this.jwtService.verifyAsync(
token,
{
secret: JWT_SECRET,
},
);
if (payload.sub) {
request['user'] = await this.userService.findOne(payload.sub);
}
} catch {
return true;
}
return true;
}
}

View File

@@ -11,7 +11,7 @@ import { JWT_SECRET } from '../consts';
JwtModule.register({
global: true,
secret: JWT_SECRET,
signOptions: { expiresIn: '1d' },
signOptions: { expiresIn: '1y' },
}),
],
controllers: [AuthController],

View File

@@ -1,6 +1,7 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { UserService } from '../user/user.service';
import { JwtService } from '@nestjs/jwt';
import { User } from '../user/user.entity';
export interface AccessTokenPayload {
sub: string;
@@ -19,7 +20,9 @@ export class AuthService {
): Promise<{
token: string;
}> {
const user = await this.userService.findOneByName(name);
let user = await this.userService.findOneByName(name);
if (!user && (await this.userService.noPreviousAdmins()))
user = await this.userService.create(name, password, true);
if (!(user && user.password === password)) {
throw new UnauthorizedException();

View File

@@ -1 +1 @@
export const JWT_SECRET = 'secret';
export const JWT_SECRET = Math.random().toString(36).substring(2, 15);

View File

@@ -8,13 +8,14 @@ import {
Param,
Post,
Put,
UnauthorizedException,
UseGuards,
} from '@nestjs/common';
import { UserService } from './user.service';
import { AuthGuard, GetUser } from '../auth/auth.guard';
import { ApiNotFoundResponse, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { AuthGuard, GetUser, OptionalAuthGuard } from '../auth/auth.guard';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { CreateUserDto, UpdateUserDto, UserDto } from './user.dto';
import { Settings, User } from './user.entity';
import { User } from './user.entity';
import { ApiException } from '@nanogiants/nestjs-swagger-api-exception-decorator';
@ApiTags('user')
@@ -57,18 +58,29 @@ export class UserController {
return UserDto.fromEntity(user);
}
// @Get('isSetupDone')
// @ApiOkResponse({ description: 'Setup done', type: Boolean })
// async isSetupDone() {
// return this.userService.noPreviousAdmins();
// }
@UseGuards(OptionalAuthGuard)
@HttpCode(HttpStatus.OK)
@Post()
async create(
@Body()
userCreateDto: CreateUserDto,
@GetUser() callerUser: User | undefined,
) {
const canCreateAdmin = await this.userService.noPreviousAdmins();
const canCreateUser =
(await this.userService.noPreviousAdmins()) || callerUser?.isAdmin;
if (!canCreateUser) throw new UnauthorizedException();
const user = await this.userService.create(
userCreateDto.name,
userCreateDto.password,
canCreateAdmin && userCreateDto.isAdmin,
userCreateDto.isAdmin,
);
return UserDto.fromEntity(user);

View File

@@ -21,6 +21,11 @@ export class ReiverrApi implements Api<paths> {
});
}
isSetupDone = async (): Promise<boolean> =>
this.getClient()
?.GET('/user/isSetupDone')
.then((res) => res.data || false) || false;
async getUser() {
const res = await this.getClient()?.GET('/user', {});
return res.data;

View File

@@ -13,6 +13,9 @@ export interface paths {
get: operations["UserController_findById"];
put: operations["UserController_updateUser"];
};
"/user/isSetupDone": {
get: operations["UserController_isSetupDone"];
};
"/auth": {
post: operations["AuthController_signIn"];
};
@@ -175,6 +178,16 @@ export interface operations {
};
};
};
UserController_isSetupDone: {
responses: {
/** @description Setup done */
200: {
content: {
"application/json": boolean;
};
};
};
};
AuthController_signIn: {
requestBody: {
content: {

View File

@@ -5,8 +5,8 @@
import TextField from '../components/TextField.svelte';
import Button from '../components/Button.svelte';
let name: string = 'test';
let password: string = 'test';
let name: string = '';
let password: string = '';
let error: string | undefined = undefined;
let loading = false;
@@ -36,26 +36,34 @@
}
</script>
<Container
class="w-full h-full max-w-xs mx-auto flex flex-col items-center justify-center"
focusOnMount
>
<h1 class="font-semibold tracking-wide text-xl w-full mb-4">Login to Reiverr</h1>
<div class="w-full h-full flex items-center justify-center">
<Container class="flex flex-col bg-primary-800 rounded-2xl p-10 shadow-xl max-w-lg" focusOnMount>
<h1 class="header2 w-full mb-2">Login to Reiverr</h1>
<div class="header1 mb-4">
If this is your first time logging in, a new account will be created based on your
credentials.
</div>
<TextField
value={$appState.serverBaseUrl}
on:change={(e) => appState.setBaseUrl(e.detail)}
class="mb-4 w-full"
>
Server
</TextField>
<TextField
value={$appState.serverBaseUrl}
on:change={(e) => appState.setBaseUrl(e.detail)}
class="mb-4 w-full"
>
Server
</TextField>
<TextField bind:value={name} class="mb-4 w-full">Name</TextField>
<TextField bind:value={password} type="password" class="mb-8 w-full">Name</TextField>
<TextField bind:value={name} class="mb-4 w-full">Name</TextField>
<TextField bind:value={password} type="password" class="mb-8 w-full">Password</TextField>
<Button disabled={loading} on:clickOrSelect={handleLogin} class="mb-4 w-full">Submit</Button>
<Button
type="primary-dark"
disabled={loading}
on:clickOrSelect={handleLogin}
class="mb-4 w-full">Submit</Button
>
{#if error}
<div class="text-red-300 text-center">{error}</div>
{/if}
</Container>
{#if error}
<div class="text-red-300 text-center">{error}</div>
{/if}
</Container>
</div>

View File

@@ -145,7 +145,7 @@
'text-primary-500': hasFocus
})}
>
Interface
General
</span>
</Container>
<Container
@@ -198,6 +198,14 @@
localSettings.update((p) => ({ ...p, useCssTransitions: detail }))}
/>
</div>
<div class="flex items-center justify-between text-lg font-medium text-secondary-100 py-2">
<label class="mr-2">Check for Updates</label>
<Toggle
checked={$localSettings.checkForUpdates}
on:change={({ detail }) =>
localSettings.update((p) => ({ ...p, checkForUpdates: detail }))}
/>
</div>
</Tab>
<Tab {...tab} tab={Tabs.Integrations} class="">

View File

@@ -4,7 +4,7 @@
import Button from '../components/Button.svelte';
import { appState } from '../stores/app-state.store';
import { tmdbApi } from '../apis/tmdb/tmdb-api';
import { ArrowRight, ExternalLink } from 'radix-icons-svelte';
import { ArrowLeft, ArrowRight, CheckCircled, ExternalLink } from 'radix-icons-svelte';
import TextField from '../components/TextField.svelte';
import { jellyfinApi, type JellyfinUser } from '../apis/jellyfin/jellyfin-api';
import SelectField from '../components/SelectField.svelte';
@@ -13,6 +13,7 @@
import { radarrApi } from '../apis/radarr/radarr-api';
import { get } from 'svelte/store';
import { useTabs } from '../components/Tab/Tab';
import classNames from 'classnames';
enum Tabs {
Welcome,
@@ -20,6 +21,7 @@
Jellyfin,
Sonarr,
Radarr,
Complete,
SelectUser = Jellyfin + 0.1,
TmdbConnect = Tmdb + 0.1
@@ -123,7 +125,7 @@
}
}));
tab.next();
tab.set(Tabs.Jellyfin);
});
}
@@ -409,8 +411,27 @@
{#if radarrBaseUrl && radarrApiKey}
<Button type="primary-dark" action={handleConnectRadarr}>Connect</Button>
{:else}
<Button type="primary-dark" on:clickOrSelect={finalizeSetup}>Skip</Button>
<Button type="primary-dark" on:clickOrSelect={() => tab.next()}>Skip</Button>
{/if}
</Container>
</Tab>
<Tab {...tab} tab={Tabs.Complete} class={classNames(tabContainer, 'w-full')}>
<div class="flex items-center justify-center text-secondary-500 mb-4">
<CheckCircled size={64} />
</div>
<h1 class="header2 text-center w-full">All Set!</h1>
<div class="header1 mb-8 text-center">Reiverr is now ready to use.</div>
<Container direction="horizontal" class="inline-flex space-x-4 w-full">
<Button type="primary-dark" on:clickOrSelect={() => tab.previous()} icon={ArrowLeft}
>Back</Button
>
<div class="flex-1">
<Button type="primary-dark" on:clickOrSelect={finalizeSetup} iconAbsolute={ArrowRight}
>Done</Button
>
</div>
</Container>
</Tab>
</Container>

View File

@@ -95,7 +95,8 @@ export const appStateUser = derived(appState, ($state) => $state.user);
authenticationStore.subscribe((auth) => {
if (auth.token) {
getReiverrApiClient(auth.serverBaseUrl, auth.token)
reiverrApi
.getClient(auth.serverBaseUrl, auth.token)
?.GET('/user', {})
.then((res) => res.data)
.then((user) => appState.setUser(user || null))