Merge branch 'user-info' into 'main'
Add user info/profile page See merge request root/fileserver!5
This commit is contained in:
		@@ -23,14 +23,28 @@ export class LoginRequest extends SignUpRequest {
 | 
				
			|||||||
	otp?: string;
 | 
						otp?: string;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export class TfaComplete extends BaseRequest {
 | 
					 | 
				
			||||||
	@IsNotEmpty()
 | 
					 | 
				
			||||||
	@IsString()
 | 
					 | 
				
			||||||
	code: string;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export class TfaSetup extends BaseRequest {
 | 
					export class TfaSetup extends BaseRequest {
 | 
				
			||||||
	@IsNotEmpty()
 | 
						@IsNotEmpty()
 | 
				
			||||||
	@IsBoolean()
 | 
						@IsBoolean()
 | 
				
			||||||
	mail: boolean;
 | 
						mail: boolean;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export class TfaComplete extends BaseRequest {
 | 
				
			||||||
 | 
						@IsNotEmpty()
 | 
				
			||||||
 | 
						@IsBoolean()
 | 
				
			||||||
 | 
						mail: boolean;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						@IsNotEmpty()
 | 
				
			||||||
 | 
						@IsString()
 | 
				
			||||||
 | 
						code: string;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export class ChangePasswordRequest extends BaseRequest {
 | 
				
			||||||
 | 
						@IsNotEmpty()
 | 
				
			||||||
 | 
						@IsString()
 | 
				
			||||||
 | 
						oldPassword: string;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						@IsNotEmpty()
 | 
				
			||||||
 | 
						@IsString()
 | 
				
			||||||
 | 
						newPassword: string;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -35,4 +35,6 @@ export class RemoveTfaResponse extends SuccessResponse {}
 | 
				
			|||||||
export class RequestEmailTfaResponse extends SuccessResponse {}
 | 
					export class RequestEmailTfaResponse extends SuccessResponse {}
 | 
				
			||||||
export class TfaCompletedResponse extends SuccessResponse {}
 | 
					export class TfaCompletedResponse extends SuccessResponse {}
 | 
				
			||||||
export class SignupResponse extends SuccessResponse {}
 | 
					export class SignupResponse extends SuccessResponse {}
 | 
				
			||||||
 | 
					export class ChangePasswordResponse extends SuccessResponse {}
 | 
				
			||||||
 | 
					export class LogoutAllResponse extends SuccessResponse {}
 | 
				
			||||||
export class RefreshResponse extends LoginResponse {}
 | 
					export class RefreshResponse extends LoginResponse {}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,3 +1,4 @@
 | 
				
			|||||||
export * from './base';
 | 
					export * from './base';
 | 
				
			||||||
export * as Auth from './auth';
 | 
					export * as Auth from './auth';
 | 
				
			||||||
export * as FS from './fs';
 | 
					export * as FS from './fs';
 | 
				
			||||||
 | 
					export * as User from './user';
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										27
									
								
								dto/src/responses/user.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								dto/src/responses/user.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,27 @@
 | 
				
			|||||||
 | 
					import { SuccessResponse } from './base';
 | 
				
			||||||
 | 
					import { ValidateConstructor } from '../utils';
 | 
				
			||||||
 | 
					import { IsBoolean, IsNotEmpty, IsString } from 'class-validator';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@ValidateConstructor
 | 
				
			||||||
 | 
					export class UserInfoResponse extends SuccessResponse {
 | 
				
			||||||
 | 
						constructor(name: string, gitlab: boolean, tfaEnabled: boolean) {
 | 
				
			||||||
 | 
							super();
 | 
				
			||||||
 | 
							this.name = name;
 | 
				
			||||||
 | 
							this.gitlab = gitlab;
 | 
				
			||||||
 | 
							this.tfaEnabled = tfaEnabled;
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						@IsNotEmpty()
 | 
				
			||||||
 | 
						@IsString()
 | 
				
			||||||
 | 
						name: string;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						@IsBoolean()
 | 
				
			||||||
 | 
						gitlab: boolean;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						@IsBoolean()
 | 
				
			||||||
 | 
						tfaEnabled: boolean;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export class DeleteUserResponse extends SuccessResponse {}
 | 
				
			||||||
 | 
					export class ChangePasswordResponse extends SuccessResponse {}
 | 
				
			||||||
 | 
					export class LogoutAllResponse extends SuccessResponse {}
 | 
				
			||||||
@@ -27,9 +27,13 @@ provide<TokenInjectType>('jwt', {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
	<nav>
 | 
						<nav>
 | 
				
			||||||
		<router-link to="/login" v-if="jwt != null" @click="logout()">
 | 
							<template v-if="jwt != null">
 | 
				
			||||||
			Logout
 | 
								<router-link to="/">Files</router-link>
 | 
				
			||||||
		</router-link>
 | 
								<span style="margin-left: 2em" />
 | 
				
			||||||
 | 
								<router-link to="/profile">Profile</router-link>
 | 
				
			||||||
 | 
								<span style="margin-left: 2em" />
 | 
				
			||||||
 | 
								<router-link to="/login" @click="logout()">Logout</router-link>
 | 
				
			||||||
 | 
							</template>
 | 
				
			||||||
	</nav>
 | 
						</nav>
 | 
				
			||||||
	<router-view />
 | 
						<router-view />
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -28,3 +28,66 @@ export const refresh_token = (
 | 
				
			|||||||
	token: string
 | 
						token: string
 | 
				
			||||||
): Promise<Responses.Auth.RefreshResponse | Responses.ErrorResponse> =>
 | 
					): Promise<Responses.Auth.RefreshResponse | Responses.ErrorResponse> =>
 | 
				
			||||||
	post_token('/api/auth/refresh', {}, token);
 | 
						post_token('/api/auth/refresh', {}, token);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const change_password = (
 | 
				
			||||||
 | 
						oldPw: string,
 | 
				
			||||||
 | 
						newPw: string,
 | 
				
			||||||
 | 
						token: string
 | 
				
			||||||
 | 
					): Promise<Responses.Auth.ChangePasswordResponse | Responses.ErrorResponse> =>
 | 
				
			||||||
 | 
						post_token<Requests.Auth.ChangePasswordRequest>(
 | 
				
			||||||
 | 
							'/api/auth/change_password',
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								oldPassword: oldPw,
 | 
				
			||||||
 | 
								newPassword: newPw
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							token
 | 
				
			||||||
 | 
						);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const logout_all = (
 | 
				
			||||||
 | 
						token: string
 | 
				
			||||||
 | 
					): Promise<Responses.Auth.LogoutAllResponse | Responses.ErrorResponse> =>
 | 
				
			||||||
 | 
						post_token('/api/auth/logout_all', {}, token);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function tfa_setup(
 | 
				
			||||||
 | 
						mail: false,
 | 
				
			||||||
 | 
						token: string
 | 
				
			||||||
 | 
					): Promise<Responses.Auth.RequestTotpTfaResponse | Responses.ErrorResponse>;
 | 
				
			||||||
 | 
					export function tfa_setup(
 | 
				
			||||||
 | 
						mail: true,
 | 
				
			||||||
 | 
						token: string
 | 
				
			||||||
 | 
					): Promise<Responses.Auth.RequestEmailTfaResponse | Responses.ErrorResponse>;
 | 
				
			||||||
 | 
					export function tfa_setup(
 | 
				
			||||||
 | 
						mail: boolean,
 | 
				
			||||||
 | 
						token: string
 | 
				
			||||||
 | 
					): Promise<
 | 
				
			||||||
 | 
						| Responses.Auth.RequestEmailTfaResponse
 | 
				
			||||||
 | 
						| Responses.Auth.RequestTotpTfaResponse
 | 
				
			||||||
 | 
						| Responses.ErrorResponse
 | 
				
			||||||
 | 
					> {
 | 
				
			||||||
 | 
						return post_token<Requests.Auth.TfaSetup>(
 | 
				
			||||||
 | 
							'/api/auth/2fa/setup',
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								mail
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							token
 | 
				
			||||||
 | 
						);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const tfa_complete = (
 | 
				
			||||||
 | 
						mail: boolean,
 | 
				
			||||||
 | 
						code: string,
 | 
				
			||||||
 | 
						token: string
 | 
				
			||||||
 | 
					): Promise<Responses.Auth.TfaCompletedResponse | Responses.ErrorResponse> =>
 | 
				
			||||||
 | 
						post_token<Requests.Auth.TfaComplete>(
 | 
				
			||||||
 | 
							'/api/auth/2fa/complete',
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								mail,
 | 
				
			||||||
 | 
								code
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							token
 | 
				
			||||||
 | 
						);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const tfa_disable = (
 | 
				
			||||||
 | 
						token: string
 | 
				
			||||||
 | 
					): Promise<Responses.Auth.RemoveTfaResponse | Responses.ErrorResponse> =>
 | 
				
			||||||
 | 
						post_token('/api/auth/2fa/disable', {}, token);
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -3,4 +3,5 @@ export { Requests, Responses } from 'dto';
 | 
				
			|||||||
export { isErrorResponse } from './base';
 | 
					export { isErrorResponse } from './base';
 | 
				
			||||||
export * as Auth from './auth';
 | 
					export * as Auth from './auth';
 | 
				
			||||||
export * as FS from './fs';
 | 
					export * as FS from './fs';
 | 
				
			||||||
 | 
					export * as User from './user';
 | 
				
			||||||
export * from './util';
 | 
					export * from './util';
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										12
									
								
								frontend/src/api/user.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								frontend/src/api/user.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,12 @@
 | 
				
			|||||||
 | 
					import { get_token, post_token } from '@/api/base';
 | 
				
			||||||
 | 
					import { Responses } from 'dto';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const get_user_info = (
 | 
				
			||||||
 | 
						token: string
 | 
				
			||||||
 | 
					): Promise<Responses.User.UserInfoResponse | Responses.ErrorResponse> =>
 | 
				
			||||||
 | 
						get_token('/api/user/info', token);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const delete_user = (
 | 
				
			||||||
 | 
						token: string
 | 
				
			||||||
 | 
					): Promise<Responses.User.DeleteUserResponse | Responses.ErrorResponse> =>
 | 
				
			||||||
 | 
						post_token('/api/user/delete', {}, token);
 | 
				
			||||||
@@ -5,6 +5,8 @@ import HomeView from '@/views/HomeView.vue';
 | 
				
			|||||||
import AboutView from '@/views/AboutView.vue';
 | 
					import AboutView from '@/views/AboutView.vue';
 | 
				
			||||||
import FSView from '@/views/FSView.vue';
 | 
					import FSView from '@/views/FSView.vue';
 | 
				
			||||||
import SetTokenView from '@/views/SetTokenView.vue';
 | 
					import SetTokenView from '@/views/SetTokenView.vue';
 | 
				
			||||||
 | 
					import ProfileView from '@/views/ProfileView.vue';
 | 
				
			||||||
 | 
					import TFAView from '@/views/TFAView.vue';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const routes: Array<RouteRecordRaw> = [
 | 
					const routes: Array<RouteRecordRaw> = [
 | 
				
			||||||
	{
 | 
						{
 | 
				
			||||||
@@ -12,9 +14,18 @@ const routes: Array<RouteRecordRaw> = [
 | 
				
			|||||||
		name: 'home',
 | 
							name: 'home',
 | 
				
			||||||
		component: HomeView
 | 
							component: HomeView
 | 
				
			||||||
	},
 | 
						},
 | 
				
			||||||
 | 
						{
 | 
				
			||||||
 | 
							path: '/profile',
 | 
				
			||||||
 | 
							name: 'profile',
 | 
				
			||||||
 | 
							component: ProfileView
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
						{
 | 
				
			||||||
 | 
							path: '/profile/2fa-enable',
 | 
				
			||||||
 | 
							name: '2fa',
 | 
				
			||||||
 | 
							component: TFAView
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
	{
 | 
						{
 | 
				
			||||||
		path: '/about',
 | 
							path: '/about',
 | 
				
			||||||
		name: 'about',
 | 
					 | 
				
			||||||
		component: AboutView
 | 
							component: AboutView
 | 
				
			||||||
	},
 | 
						},
 | 
				
			||||||
	{
 | 
						{
 | 
				
			||||||
@@ -27,14 +38,15 @@ const routes: Array<RouteRecordRaw> = [
 | 
				
			|||||||
		name: 'signup',
 | 
							name: 'signup',
 | 
				
			||||||
		component: SignupView
 | 
							component: SignupView
 | 
				
			||||||
	},
 | 
						},
 | 
				
			||||||
	{
 | 
					 | 
				
			||||||
		path: '/set_token',
 | 
					 | 
				
			||||||
		component: SetTokenView
 | 
					 | 
				
			||||||
	},
 | 
					 | 
				
			||||||
	{
 | 
						{
 | 
				
			||||||
		path: '/fs/:node_id',
 | 
							path: '/fs/:node_id',
 | 
				
			||||||
		name: 'fs',
 | 
							name: 'fs',
 | 
				
			||||||
		component: FSView
 | 
							component: FSView
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						{
 | 
				
			||||||
 | 
							path: '/set_token',
 | 
				
			||||||
 | 
							component: SetTokenView
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
];
 | 
					];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -13,7 +13,7 @@ async function start_redirect() {
 | 
				
			|||||||
	if (!token) return;
 | 
						if (!token) return;
 | 
				
			||||||
	const root = await FS.get_root(token);
 | 
						const root = await FS.get_root(token);
 | 
				
			||||||
	if (isErrorResponse(root)) return jwt.logout();
 | 
						if (isErrorResponse(root)) return jwt.logout();
 | 
				
			||||||
	await router.push({
 | 
						await router.replace({
 | 
				
			||||||
		name: 'fs',
 | 
							name: 'fs',
 | 
				
			||||||
		params: { node_id: root.rootId }
 | 
							params: { node_id: root.rootId }
 | 
				
			||||||
	});
 | 
						});
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -48,14 +48,14 @@ async function login() {
 | 
				
			|||||||
	<template v-if="!requestOtp">
 | 
						<template v-if="!requestOtp">
 | 
				
			||||||
		<input type="email" placeholder="Email" v-model="username" />
 | 
							<input type="email" placeholder="Email" v-model="username" />
 | 
				
			||||||
		<input type="password" placeholder="Password" v-model="password" />
 | 
							<input type="password" placeholder="Password" v-model="password" />
 | 
				
			||||||
 | 
							<a href="/api/auth/gitlab">Login with gitlab</a>
 | 
				
			||||||
 | 
							<router-link to="signup">Signup instead?</router-link>
 | 
				
			||||||
	</template>
 | 
						</template>
 | 
				
			||||||
	<template v-else>
 | 
						<template v-else>
 | 
				
			||||||
		<div>Please input your 2 factor authentication code</div>
 | 
							<div>Please input your 2 factor authentication code</div>
 | 
				
			||||||
		<input type="text" placeholder="Code" v-model="otp" />
 | 
							<input type="text" placeholder="Code" v-model="otp" />
 | 
				
			||||||
	</template>
 | 
						</template>
 | 
				
			||||||
	<button @click="login()">Login</button>
 | 
						<button @click="login()">Login</button>
 | 
				
			||||||
	<a href="/api/auth/gitlab">Login with gitlab</a>
 | 
					 | 
				
			||||||
	<router-link to="signup">Signup instead?</router-link>
 | 
					 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<style scoped></style>
 | 
					<style scoped></style>
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										124
									
								
								frontend/src/views/ProfileView.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										124
									
								
								frontend/src/views/ProfileView.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,124 @@
 | 
				
			|||||||
 | 
					<script setup lang="ts">
 | 
				
			||||||
 | 
					import { ref, inject, onBeforeMount } from 'vue';
 | 
				
			||||||
 | 
					import {
 | 
				
			||||||
 | 
						Auth,
 | 
				
			||||||
 | 
						User,
 | 
				
			||||||
 | 
						check_token,
 | 
				
			||||||
 | 
						isErrorResponse,
 | 
				
			||||||
 | 
						TokenInjectType,
 | 
				
			||||||
 | 
						Responses
 | 
				
			||||||
 | 
					} from '@/api';
 | 
				
			||||||
 | 
					import { onBeforeRouteUpdate } from 'vue-router';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const error = ref('');
 | 
				
			||||||
 | 
					const oldPw = ref('');
 | 
				
			||||||
 | 
					const newPw = ref('');
 | 
				
			||||||
 | 
					const newPw2 = ref('');
 | 
				
			||||||
 | 
					const user = ref<Responses.User.UserInfoResponse | null>(null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const jwt = inject<TokenInjectType>('jwt') as TokenInjectType;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					onBeforeRouteUpdate(async () => {
 | 
				
			||||||
 | 
						await updateProfile();
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					onBeforeMount(async () => {
 | 
				
			||||||
 | 
						await updateProfile();
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					async function updateProfile() {
 | 
				
			||||||
 | 
						const token = await check_token(jwt);
 | 
				
			||||||
 | 
						if (!token) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						const res = await User.get_user_info(token);
 | 
				
			||||||
 | 
						if (isErrorResponse(res)) return jwt.logout();
 | 
				
			||||||
 | 
						user.value = res;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					async function deleteUser() {
 | 
				
			||||||
 | 
						const token = await check_token(jwt);
 | 
				
			||||||
 | 
						if (!token) return;
 | 
				
			||||||
 | 
						await User.delete_user(token);
 | 
				
			||||||
 | 
						jwt.logout();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					async function logoutAll() {
 | 
				
			||||||
 | 
						const token = await check_token(jwt);
 | 
				
			||||||
 | 
						if (!token) return;
 | 
				
			||||||
 | 
						await Auth.logout_all(token);
 | 
				
			||||||
 | 
						jwt.logout();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					async function changePw() {
 | 
				
			||||||
 | 
						if (oldPw.value === '' || newPw.value === '' || newPw2.value === '') {
 | 
				
			||||||
 | 
							error.value = 'Password missing';
 | 
				
			||||||
 | 
							return;
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if (newPw.value !== newPw2.value) {
 | 
				
			||||||
 | 
							error.value = "Passwords don't match";
 | 
				
			||||||
 | 
							return;
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						const token = await check_token(jwt);
 | 
				
			||||||
 | 
						if (!token) return;
 | 
				
			||||||
 | 
						const res = await Auth.change_password(oldPw.value, newPw.value, token);
 | 
				
			||||||
 | 
						if (isErrorResponse(res))
 | 
				
			||||||
 | 
							error.value = 'Password change failed: ' + res.message;
 | 
				
			||||||
 | 
						else jwt.logout();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					async function tfaDisable() {
 | 
				
			||||||
 | 
						const token = await check_token(jwt);
 | 
				
			||||||
 | 
						if (!token) return;
 | 
				
			||||||
 | 
						await Auth.tfa_disable(token);
 | 
				
			||||||
 | 
						jwt.logout();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<template>
 | 
				
			||||||
 | 
						<template v-if="user">
 | 
				
			||||||
 | 
							<div v-if="error !== ''" v-text="error"></div>
 | 
				
			||||||
 | 
							<div>User: {{ user.name }}</div>
 | 
				
			||||||
 | 
							<div>Signed in with {{ user.gitlab ? 'gitlab' : 'password' }}</div>
 | 
				
			||||||
 | 
							<template v-if="!user.gitlab">
 | 
				
			||||||
 | 
								<div>
 | 
				
			||||||
 | 
									<input
 | 
				
			||||||
 | 
										type="password"
 | 
				
			||||||
 | 
										placeholder="Old password"
 | 
				
			||||||
 | 
										v-model="oldPw"
 | 
				
			||||||
 | 
									/>
 | 
				
			||||||
 | 
									<input
 | 
				
			||||||
 | 
										type="password"
 | 
				
			||||||
 | 
										placeholder="New password"
 | 
				
			||||||
 | 
										v-model="newPw"
 | 
				
			||||||
 | 
									/>
 | 
				
			||||||
 | 
									<input
 | 
				
			||||||
 | 
										type="password"
 | 
				
			||||||
 | 
										placeholder="Repeat new password"
 | 
				
			||||||
 | 
										v-model="newPw2"
 | 
				
			||||||
 | 
									/>
 | 
				
			||||||
 | 
									<button @click="changePw">Change</button>
 | 
				
			||||||
 | 
								</div>
 | 
				
			||||||
 | 
								<div>
 | 
				
			||||||
 | 
									<div>
 | 
				
			||||||
 | 
										2 Factor authentication:
 | 
				
			||||||
 | 
										{{ user.tfaEnabled ? 'Enabled' : 'Disabled' }}
 | 
				
			||||||
 | 
									</div>
 | 
				
			||||||
 | 
									<div>
 | 
				
			||||||
 | 
										<a href="#" v-if="user.tfaEnabled" @click="tfaDisable">
 | 
				
			||||||
 | 
											Disable
 | 
				
			||||||
 | 
										</a>
 | 
				
			||||||
 | 
										<router-link to="/profile/2fa-enable" v-else>
 | 
				
			||||||
 | 
											Enable
 | 
				
			||||||
 | 
										</router-link>
 | 
				
			||||||
 | 
									</div>
 | 
				
			||||||
 | 
								</div>
 | 
				
			||||||
 | 
							</template>
 | 
				
			||||||
 | 
							<div>
 | 
				
			||||||
 | 
								<a href="#" @click="logoutAll">Logout everywhere</a>
 | 
				
			||||||
 | 
								<a href="#" @click="deleteUser">Delete Account</a>
 | 
				
			||||||
 | 
							</div>
 | 
				
			||||||
 | 
						</template>
 | 
				
			||||||
 | 
						<template v-else>
 | 
				
			||||||
 | 
							<div>Loading...</div>
 | 
				
			||||||
 | 
						</template>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<style scoped></style>
 | 
				
			||||||
@@ -2,9 +2,9 @@
 | 
				
			|||||||
import { ref } from 'vue';
 | 
					import { ref } from 'vue';
 | 
				
			||||||
import { Auth, isErrorResponse } from '@/api';
 | 
					import { Auth, isErrorResponse } from '@/api';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
let username = ref('');
 | 
					const username = ref('');
 | 
				
			||||||
let password = ref('');
 | 
					const password = ref('');
 | 
				
			||||||
let password2 = ref('');
 | 
					const password2 = ref('');
 | 
				
			||||||
const error = ref('');
 | 
					const error = ref('');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
async function signup() {
 | 
					async function signup() {
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										89
									
								
								frontend/src/views/TFAView.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								frontend/src/views/TFAView.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,89 @@
 | 
				
			|||||||
 | 
					<script setup lang="ts">
 | 
				
			||||||
 | 
					import { ref, inject } from 'vue';
 | 
				
			||||||
 | 
					import { Auth, check_token, isErrorResponse, TokenInjectType } from '@/api';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					enum state {
 | 
				
			||||||
 | 
						SELECT,
 | 
				
			||||||
 | 
						MAIL,
 | 
				
			||||||
 | 
						TOTP
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const currentState = ref<state>(state.SELECT);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const error = ref('');
 | 
				
			||||||
 | 
					const qrImage = ref('');
 | 
				
			||||||
 | 
					const secret = ref('');
 | 
				
			||||||
 | 
					const code = ref('');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const jwt = inject<TokenInjectType>('jwt') as TokenInjectType;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					async function selectMail() {
 | 
				
			||||||
 | 
						const token = await check_token(jwt);
 | 
				
			||||||
 | 
						if (!token) return;
 | 
				
			||||||
 | 
						error.value = 'Working...';
 | 
				
			||||||
 | 
						const res = await Auth.tfa_setup(true, token);
 | 
				
			||||||
 | 
						if (isErrorResponse(res))
 | 
				
			||||||
 | 
							error.value = 'Failed to select 2fa type: ' + res.message;
 | 
				
			||||||
 | 
						else {
 | 
				
			||||||
 | 
							error.value = '';
 | 
				
			||||||
 | 
							currentState.value = state.MAIL;
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					async function selectTotp() {
 | 
				
			||||||
 | 
						const token = await check_token(jwt);
 | 
				
			||||||
 | 
						if (!token) return;
 | 
				
			||||||
 | 
						error.value = 'Working...';
 | 
				
			||||||
 | 
						const res = await Auth.tfa_setup(false, token);
 | 
				
			||||||
 | 
						if (isErrorResponse(res))
 | 
				
			||||||
 | 
							error.value = 'Failed to select 2fa type: ' + res.message;
 | 
				
			||||||
 | 
						else {
 | 
				
			||||||
 | 
							qrImage.value = res.qrCode;
 | 
				
			||||||
 | 
							secret.value = res.secret;
 | 
				
			||||||
 | 
							error.value = '';
 | 
				
			||||||
 | 
							currentState.value = state.TOTP;
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					async function submit() {
 | 
				
			||||||
 | 
						const token = await check_token(jwt);
 | 
				
			||||||
 | 
						if (!token) return;
 | 
				
			||||||
 | 
						error.value = 'Working...';
 | 
				
			||||||
 | 
						const res = await Auth.tfa_complete(
 | 
				
			||||||
 | 
							currentState.value === state.MAIL,
 | 
				
			||||||
 | 
							code.value,
 | 
				
			||||||
 | 
							token
 | 
				
			||||||
 | 
						);
 | 
				
			||||||
 | 
						if (isErrorResponse(res))
 | 
				
			||||||
 | 
							error.value = 'Failed to submit code: ' + res.message;
 | 
				
			||||||
 | 
						else jwt.logout();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<template>
 | 
				
			||||||
 | 
						<div v-if="error !== ''" v-text="error"></div>
 | 
				
			||||||
 | 
						<template v-if="currentState === state.SELECT">
 | 
				
			||||||
 | 
							<div>Select 2 Factor authentication type:</div>
 | 
				
			||||||
 | 
							<div>
 | 
				
			||||||
 | 
								<button @click="selectMail">Mail</button>
 | 
				
			||||||
 | 
								<button @click="selectTotp">Google Authenticator</button>
 | 
				
			||||||
 | 
							</div>
 | 
				
			||||||
 | 
						</template>
 | 
				
			||||||
 | 
						<template v-else-if="currentState === state.MAIL">
 | 
				
			||||||
 | 
							<div>Please enter the code you got by mail</div>
 | 
				
			||||||
 | 
							<input type="text" placeholder="Code" v-model="code" />
 | 
				
			||||||
 | 
							<button @click="submit()">Submit</button>
 | 
				
			||||||
 | 
						</template>
 | 
				
			||||||
 | 
						<template v-else>
 | 
				
			||||||
 | 
							<img :src="qrImage" alt="QrCode" />
 | 
				
			||||||
 | 
							<details>
 | 
				
			||||||
 | 
								<summary>Show manual input code</summary>
 | 
				
			||||||
 | 
								{{ secret }}
 | 
				
			||||||
 | 
							</details>
 | 
				
			||||||
 | 
							<div>Please enter the current code</div>
 | 
				
			||||||
 | 
							<input type="text" placeholder="Code" v-model="code" />
 | 
				
			||||||
 | 
							<button @click="submit()">Submit</button>
 | 
				
			||||||
 | 
						</template>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<style scoped></style>
 | 
				
			||||||
@@ -18,7 +18,9 @@
 | 
				
			|||||||
		"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
 | 
							"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
 | 
				
			||||||
		"test:e2e": "jest --config ./test/jest-e2e.json",
 | 
							"test:e2e": "jest --config ./test/jest-e2e.json",
 | 
				
			||||||
		"webpack": "webpack --config webpack.config.ts",
 | 
							"webpack": "webpack --config webpack.config.ts",
 | 
				
			||||||
		"genapi": "ts-node tools/apigen.ts"
 | 
							"genapi": "ts-node tools/apigen.ts",
 | 
				
			||||||
 | 
							"updateDto": "cd dto && yarn build && cd .. && yarn add ./dto && cd frontend && yarn add ../dto",
 | 
				
			||||||
 | 
							"lint-fix-all": "yarn lint-fix && cd dto && yarn lint-fix && cd ../frontend && yarn lint --fix"
 | 
				
			||||||
	},
 | 
						},
 | 
				
			||||||
	"dependencies": {
 | 
						"dependencies": {
 | 
				
			||||||
		"@fastify/multipart": "^7.1.0",
 | 
							"@fastify/multipart": "^7.1.0",
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -15,7 +15,7 @@ import {
 | 
				
			|||||||
import { AuthService } from '../services/auth';
 | 
					import { AuthService } from '../services/auth';
 | 
				
			||||||
import { AuthGuard } from '@nestjs/passport';
 | 
					import { AuthGuard } from '@nestjs/passport';
 | 
				
			||||||
import { Public } from '../authguards';
 | 
					import { Public } from '../authguards';
 | 
				
			||||||
import { Responses, Requests } from 'dto';
 | 
					import { Requests, Responses } from 'dto';
 | 
				
			||||||
import { tfaTypes } from '../entities';
 | 
					import { tfaTypes } from '../entities';
 | 
				
			||||||
import { toDataURL } from 'qrcode';
 | 
					import { toDataURL } from 'qrcode';
 | 
				
			||||||
import * as base32 from 'thirty-two';
 | 
					import * as base32 from 'thirty-two';
 | 
				
			||||||
@@ -48,12 +48,22 @@ export default class AuthController {
 | 
				
			|||||||
		);
 | 
							);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	async tfa(
 | 
						@Post('2fa/disable')
 | 
				
			||||||
		req,
 | 
						async tfaDisable(
 | 
				
			||||||
		code: string,
 | 
							@Request() req
 | 
				
			||||||
		type: tfaTypes
 | 
						): Promise<Responses.Auth.RemoveTfaResponse> {
 | 
				
			||||||
 | 
							await this.authService.setTfaType(req.user, tfaTypes.NONE);
 | 
				
			||||||
 | 
							await this.authService.revokeAll(req.user);
 | 
				
			||||||
 | 
							return new Responses.Auth.RemoveTfaResponse();
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						@Post('2fa/complete')
 | 
				
			||||||
 | 
						async tfaMail(
 | 
				
			||||||
 | 
							@Request() req,
 | 
				
			||||||
 | 
							@Body(new ValidationPipe()) data: Requests.Auth.TfaComplete
 | 
				
			||||||
	): Promise<Responses.Auth.TfaCompletedResponse> {
 | 
						): Promise<Responses.Auth.TfaCompletedResponse> {
 | 
				
			||||||
		if (!(await this.authService.verifyTfa(req.user, code, type))) {
 | 
							const type = data.mail ? tfaTypes.EMAIL : tfaTypes.TOTP;
 | 
				
			||||||
 | 
							if (!(await this.authService.verifyTfa(req.user, data.code, type))) {
 | 
				
			||||||
			throw new UnauthorizedException('Incorrect 2fa');
 | 
								throw new UnauthorizedException('Incorrect 2fa');
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		await this.authService.setTfaType(req.user, type);
 | 
							await this.authService.setTfaType(req.user, type);
 | 
				
			||||||
@@ -61,23 +71,7 @@ export default class AuthController {
 | 
				
			|||||||
		return new Responses.Auth.TfaCompletedResponse();
 | 
							return new Responses.Auth.TfaCompletedResponse();
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	@Post('2fa/complete/mail')
 | 
						@Post('2fa/setup')
 | 
				
			||||||
	async tfaMail(
 | 
					 | 
				
			||||||
		@Request() req,
 | 
					 | 
				
			||||||
		@Body(new ValidationPipe()) data: Requests.Auth.TfaComplete
 | 
					 | 
				
			||||||
	): Promise<Responses.Auth.TfaCompletedResponse> {
 | 
					 | 
				
			||||||
		return await this.tfa(req, data.code, tfaTypes.EMAIL);
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	@Post('2fa/complete/totp')
 | 
					 | 
				
			||||||
	async tfaTotp(
 | 
					 | 
				
			||||||
		@Request() req,
 | 
					 | 
				
			||||||
		@Body(new ValidationPipe()) data: Requests.Auth.TfaComplete
 | 
					 | 
				
			||||||
	): Promise<Responses.Auth.TfaCompletedResponse> {
 | 
					 | 
				
			||||||
		return await this.tfa(req, data.code, tfaTypes.TOTP);
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	@Get('2fa/setup')
 | 
					 | 
				
			||||||
	async setupTotp(
 | 
						async setupTotp(
 | 
				
			||||||
		@Request() req,
 | 
							@Request() req,
 | 
				
			||||||
		@Body(new ValidationPipe()) data: Requests.Auth.TfaSetup
 | 
							@Body(new ValidationPipe()) data: Requests.Auth.TfaSetup
 | 
				
			||||||
@@ -93,7 +87,7 @@ export default class AuthController {
 | 
				
			|||||||
					.encode(secret)
 | 
										.encode(secret)
 | 
				
			||||||
					.toString()}&issuer=MFileserver`
 | 
										.toString()}&issuer=MFileserver`
 | 
				
			||||||
			),
 | 
								),
 | 
				
			||||||
			secret
 | 
								base32.encode(secret).toString()
 | 
				
			||||||
		);
 | 
							);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -134,4 +128,23 @@ export default class AuthController {
 | 
				
			|||||||
			url: `/set_token?token=${token}`
 | 
								url: `/set_token?token=${token}`
 | 
				
			||||||
		};
 | 
							};
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						@Post('change_password')
 | 
				
			||||||
 | 
						async changePassword(
 | 
				
			||||||
 | 
							@Request() req,
 | 
				
			||||||
 | 
							@Body(new ValidationPipe()) data: Requests.Auth.ChangePasswordRequest
 | 
				
			||||||
 | 
						): Promise<Responses.Auth.ChangePasswordResponse> {
 | 
				
			||||||
 | 
							await this.authService.changePassword(
 | 
				
			||||||
 | 
								req.user,
 | 
				
			||||||
 | 
								data.oldPassword,
 | 
				
			||||||
 | 
								data.newPassword
 | 
				
			||||||
 | 
							);
 | 
				
			||||||
 | 
							return new Responses.Auth.ChangePasswordResponse();
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						@Post('logout_all')
 | 
				
			||||||
 | 
						async logoutAll(@Request() req): Promise<Responses.Auth.LogoutAllResponse> {
 | 
				
			||||||
 | 
							await this.authService.revokeAll(req.user);
 | 
				
			||||||
 | 
							return new Responses.Auth.LogoutAllResponse();
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										27
									
								
								src/controller/user.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								src/controller/user.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,27 @@
 | 
				
			|||||||
 | 
					import { Controller, Get, Post, Request } from '@nestjs/common';
 | 
				
			||||||
 | 
					import { AuthService } from '../services/auth';
 | 
				
			||||||
 | 
					import { Responses } from 'dto';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@Controller('api/user')
 | 
				
			||||||
 | 
					export default class UserController {
 | 
				
			||||||
 | 
						constructor(private authService: AuthService) {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						@Get('info')
 | 
				
			||||||
 | 
						async getUserInfo(
 | 
				
			||||||
 | 
							@Request() req
 | 
				
			||||||
 | 
						): Promise<Responses.User.UserInfoResponse> {
 | 
				
			||||||
 | 
							return new Responses.User.UserInfoResponse(
 | 
				
			||||||
 | 
								req.user.name,
 | 
				
			||||||
 | 
								req.user.isGitlabUser,
 | 
				
			||||||
 | 
								this.authService.requiresTfa(req.user)
 | 
				
			||||||
 | 
							);
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						@Post('delete')
 | 
				
			||||||
 | 
						async deleteUser(
 | 
				
			||||||
 | 
							@Request() req
 | 
				
			||||||
 | 
						): Promise<Responses.User.DeleteUserResponse> {
 | 
				
			||||||
 | 
							await this.authService.deleteUser(req.user);
 | 
				
			||||||
 | 
							return new Responses.User.DeleteUserResponse();
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -6,8 +6,9 @@ import {
 | 
				
			|||||||
	AuthLocalService,
 | 
						AuthLocalService,
 | 
				
			||||||
	AuthJwtService
 | 
						AuthJwtService
 | 
				
			||||||
} from '../services/auth';
 | 
					} from '../services/auth';
 | 
				
			||||||
import AuthController from '../controller/auth';
 | 
					 | 
				
			||||||
import FileSystemService from '../services/filesystem';
 | 
					import FileSystemService from '../services/filesystem';
 | 
				
			||||||
 | 
					import AuthController from '../controller/auth';
 | 
				
			||||||
 | 
					import UserController from '../controller/user';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@Module({
 | 
					@Module({
 | 
				
			||||||
	imports: [TypeOrmModule.forFeature([User, INode, JWTToken])],
 | 
						imports: [TypeOrmModule.forFeature([User, INode, JWTToken])],
 | 
				
			||||||
@@ -17,6 +18,6 @@ import FileSystemService from '../services/filesystem';
 | 
				
			|||||||
		AuthJwtService,
 | 
							AuthJwtService,
 | 
				
			||||||
		FileSystemService
 | 
							FileSystemService
 | 
				
			||||||
	],
 | 
						],
 | 
				
			||||||
	controllers: [AuthController]
 | 
						controllers: [AuthController, UserController]
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
export default class AuthModule {}
 | 
					export default class AuthModule {}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,5 +1,6 @@
 | 
				
			|||||||
import {
 | 
					import {
 | 
				
			||||||
	BadRequestException,
 | 
						BadRequestException,
 | 
				
			||||||
 | 
						ForbiddenException,
 | 
				
			||||||
	Injectable,
 | 
						Injectable,
 | 
				
			||||||
	UnauthorizedException
 | 
						UnauthorizedException
 | 
				
			||||||
} from '@nestjs/common';
 | 
					} from '@nestjs/common';
 | 
				
			||||||
@@ -312,6 +313,23 @@ export class AuthService {
 | 
				
			|||||||
		}
 | 
							}
 | 
				
			||||||
		return info && info.username == user.name;
 | 
							return info && info.username == user.name;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						async deleteUser(user: User): Promise<void> {
 | 
				
			||||||
 | 
							await this.revokeAll(user);
 | 
				
			||||||
 | 
							await this.fsService.delete(await user.root, true);
 | 
				
			||||||
 | 
							await this.userRepo.remove(user);
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						async changePassword(
 | 
				
			||||||
 | 
							user: User,
 | 
				
			||||||
 | 
							oldPW: string,
 | 
				
			||||||
 | 
							newPw: string
 | 
				
			||||||
 | 
						): Promise<void> {
 | 
				
			||||||
 | 
							if (!(await argon2.verify(user.password, oldPW)))
 | 
				
			||||||
 | 
								throw new ForbiddenException('Old password is wrong');
 | 
				
			||||||
 | 
							user.password = await argon2.hash(newPw);
 | 
				
			||||||
 | 
							await this.revokeAll(await this.userRepo.save(user));
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@Injectable()
 | 
					@Injectable()
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -51,12 +51,12 @@ export default class FileSystemService {
 | 
				
			|||||||
		);
 | 
							);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	async delete(node: INode): Promise<void> {
 | 
						async delete(node: INode, force = false): Promise<void> {
 | 
				
			||||||
		if (node.parentId == null)
 | 
							if (node.parentId == null || force)
 | 
				
			||||||
			throw new BadRequestException("Can't delete root");
 | 
								throw new BadRequestException("Can't delete root");
 | 
				
			||||||
		if (!node.isFile)
 | 
							if (!node.isFile)
 | 
				
			||||||
			await Promise.all(
 | 
								await Promise.all(
 | 
				
			||||||
				(await node.children).map((child) => this.delete(child))
 | 
									(await node.children).map((child) => this.delete(child, force))
 | 
				
			||||||
			);
 | 
								);
 | 
				
			||||||
		else
 | 
							else
 | 
				
			||||||
			unlink(`files/${node.id}`, (err) => {
 | 
								unlink(`files/${node.id}`, (err) => {
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user