Rewrote Frontend

This commit is contained in:
2022-09-03 23:32:20 +02:00
parent 0939525cf3
commit 16876e090d
98 changed files with 4995 additions and 1757 deletions

View File

@@ -1,62 +1,113 @@
<script setup async lang="ts">
import { provide, ref } from "vue";
import { useRouter } from "vue-router";
import type { TokenInjectType } from "@/api";
import type { MenuOption } from 'naive-ui';
import { provide, ref, h } from 'vue';
import { useRouter, RouterLink } from 'vue-router';
import type { TokenInjectType } from '@/api';
import { useMessage, NMenu, NPageHeader, NIcon } from 'naive-ui';
import { BareMetalServer02 } from '@vicons/carbon';
const router = useRouter();
const message = useMessage();
const jwt = ref<string | null>(localStorage.getItem("token"));
const jwt = ref<string | null>(localStorage.getItem('token'));
function setToken(token: string) {
jwt.value = token;
localStorage.setItem("token", token);
jwt.value = token;
localStorage.setItem('token', token);
}
function logout() {
jwt.value = null;
localStorage.removeItem("token");
router.push({ name: "login" });
jwt.value = null;
localStorage.removeItem('token');
router.push({ name: 'login' });
}
provide<TokenInjectType>("jwt", {
jwt,
setToken,
logout,
provide<TokenInjectType>('jwt', {
jwt,
setToken,
logout
});
router.afterEach(() => message.destroyAll());
function handleUpdateValue(key: string) {
if (key === 'login') logout();
}
const menuOptions: MenuOption[] = [
{
label: () =>
h(
RouterLink,
{
to: '/'
},
{ default: () => 'Files' }
),
key: 'fs'
},
{
label: () =>
h(
RouterLink,
{
to: '/profile'
},
{ default: () => 'Profile' }
),
key: 'profile'
},
{
label: () =>
h(
RouterLink,
{
to: '/login'
},
{ default: () => 'Logout' }
),
key: 'login'
}
];
</script>
<template>
<nav>
<template v-if="jwt != null">
<router-link to="/">Files</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>
<router-view />
<n-page-header style="margin-bottom: 3em">
<template #title>
<n-icon class="nav-icon" size="1.5em">
<BareMetalServer02 />
</n-icon>
MFileserver
</template>
<template #extra>
<n-menu
v-if="jwt != null"
mode="horizontal"
:options="menuOptions"
@update:value="handleUpdateValue"
/>
</template>
</n-page-header>
<router-view />
</template>
<style lang="scss">
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
body {
height: 100%;
padding: 2em;
display: flex;
justify-content: center;
align-content: center;
}
nav {
padding: 30px;
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: #2c3e50;
}
a {
font-weight: bold;
color: #2c3e50;
&.router-link-exact-active {
color: #42b983;
}
}
.nav-icon {
top: 0.25em;
}
</style>

View File

@@ -1,12 +1,17 @@
<script setup lang="ts">
import App from "./App.vue";
import App from './App.vue';
import { NSpin, NMessageProvider, NDialogProvider } from 'naive-ui';
</script>
<template>
<Suspense>
<App></App>
<template #fallback>
<div>Loading...</div>
</template>
</Suspense>
<Suspense>
<n-message-provider :closable="true" :duration="5000">
<n-dialog-provider>
<App />
</n-dialog-provider>
</n-message-provider>
<template #fallback>
<div><n-spin size="small" />Loading...</div>
</template>
</Suspense>
</template>

View File

@@ -1,54 +1,55 @@
import { Requests, Responses, UserRole, get_token, post_token } from "./base";
import type { Requests, Responses } from '@/dto';
import { UserRole, get_token, post_token } from './base';
export const get_users = (token: string): Promise<Responses.Admin.GetUsers> =>
get_token("/api/admin/users", token);
export const get_users = (token: string): Promise<Responses.GetUsers> =>
get_token('/api/admin/users', token);
export const set_role = (
user: number,
role: UserRole,
token: string
): Promise<Responses.Admin.SetUserRole | Responses.ErrorResponse> =>
post_token<Requests.Admin.SetUserRole>(
"/api/admin/set_role",
{
user,
role,
},
token
);
user: number,
role: UserRole,
token: string
): Promise<Responses.Success | Responses.Error> =>
post_token<Requests.SetUserRole>(
'/api/admin/set_role',
{
user,
role
},
token
);
export const logout = (
user: number,
token: string
): Promise<Responses.Admin.LogoutAllUser | Responses.ErrorResponse> =>
post_token<Requests.Admin.LogoutAll>(
"/api/admin/logout",
{
user,
},
token
);
user: number,
token: string
): Promise<Responses.Success | Responses.Error> =>
post_token<Requests.Admin>(
'/api/admin/logout',
{
user
},
token
);
export const delete_user = (
user: number,
token: string
): Promise<Responses.Admin.DeleteUser | Responses.ErrorResponse> =>
post_token<Requests.Admin.DeleteUser>(
"/api/admin/delete",
{
user,
},
token
);
user: number,
token: string
): Promise<Responses.Success | Responses.Error> =>
post_token<Requests.Admin>(
'/api/admin/delete',
{
user
},
token
);
export const disable_tfa = (
user: number,
token: string
): Promise<Responses.Admin.DisableTfa | Responses.ErrorResponse> =>
post_token<Requests.Admin.DisableTfa>(
"/api/admin/disable_2fa",
{
user,
},
token
);
user: number,
token: string
): Promise<Responses.Success | Responses.Error> =>
post_token<Requests.Admin>(
'/api/admin/disable_2fa',
{
user
},
token
);

View File

@@ -1,93 +1,86 @@
import { Responses, Requests, post, post_token } from "./base";
import type { Requests, Responses } from '@/dto';
import { post, post_token } from './base';
export const auth_login = (
username: string,
password: string,
otp?: string
): Promise<
| Responses.Auth.LoginResponse
| Responses.Auth.TfaRequiredResponse
| Responses.ErrorResponse
> =>
post<Requests.Auth.LoginRequest>("/api/auth/login", {
username: username,
password: password,
otp: otp,
});
username: string,
password: string,
otp?: string
): Promise<Responses.Login | Responses.Success | Responses.Error> =>
post<Requests.Login>('/api/auth/login', {
username: username,
password: password,
otp: otp
});
export const auth_signup = (
username: string,
password: string
): Promise<Responses.Auth.SignupResponse | Responses.ErrorResponse> =>
post<Requests.Auth.SignUpRequest>("/api/auth/signup", {
username: username,
password: password,
});
username: string,
password: string
): Promise<Responses.Success | Responses.Error> =>
post<Requests.SignUp>('/api/auth/signup', {
username: username,
password: password
});
export const refresh_token = (
token: string
): Promise<Responses.Auth.RefreshResponse | Responses.ErrorResponse> =>
post_token("/api/auth/refresh", {}, token);
token: string
): Promise<Responses.Login | Responses.Error> =>
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
);
oldPw: string,
newPw: string,
token: string
): Promise<Responses.Success | Responses.Error> =>
post_token<Requests.ChangePassword>(
'/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);
token: string
): Promise<Responses.Success | Responses.Error> =>
post_token('/api/auth/logout_all', {}, token);
export function tfa_setup(
mail: false,
token: string
): Promise<Responses.Auth.RequestTotpTfaResponse | Responses.ErrorResponse>;
mail: false,
token: string
): Promise<Responses.RequestsTotpTfa | Responses.Error>;
export function tfa_setup(
mail: true,
token: string
): Promise<Responses.Auth.RequestEmailTfaResponse | Responses.ErrorResponse>;
mail: true,
token: string
): Promise<Responses.Success | Responses.Error>;
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
);
mail: boolean,
token: string
): Promise<Responses.Success | Responses.RequestsTotpTfa | Responses.Error> {
return post_token<Requests.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
);
mail: boolean,
code: string,
token: string
): Promise<Responses.Success | Responses.Error> =>
post_token<Requests.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);
token: string
): Promise<Responses.Success | Responses.Error> =>
post_token('/api/auth/2fa/disable', {}, token);

View File

@@ -1,62 +1,62 @@
import axios from "axios";
import { Requests, Responses, UserRole } from "../dto";
export { Requests, Responses, UserRole };
import axios from 'axios';
import type { Requests, Responses, UploadFile } from '@/dto';
import { UserRole } from '@/dto';
export { Requests, Responses, UserRole, UploadFile };
export const post = <T extends Requests.BaseRequest>(url: string, data: T) =>
axios
.post(url, data, {
headers: { "Content-type": "application/json" },
})
.then((res) => res.data)
.catch((err) => err.response.data);
export const post = <T extends Requests.Base>(url: string, data: T) =>
axios
.post(url, data, {
headers: { 'Content-type': 'application/json' }
})
.then((res) => res.data)
.catch((err) => err.response.data);
export const post_token = <T extends Requests.BaseRequest>(
url: string,
data: T,
token: string
export const post_token = <T extends Requests.Base>(
url: string,
data: T,
token: string
) =>
axios
.post(url, data, {
headers: {
Authorization: "Bearer " + token,
"Content-type": "application/json",
},
})
.then((res) => res.data)
.catch((err) => err.response.data);
axios
.post(url, data, {
headers: {
Authorization: 'Bearer ' + token,
'Content-type': 'application/json'
}
})
.then((res) => res.data)
.catch((err) => err.response.data);
export const post_token_form = (
url: string,
data: FormData,
token: string,
onProgress: (progressEvent: ProgressEvent) => void
url: string,
data: FormData,
token: string,
onProgress: (progressEvent: ProgressEvent) => void
) =>
axios
.post(url, data, {
headers: {
Authorization: "Bearer " + token,
"Content-type": "multipart/form-data",
},
onUploadProgress: onProgress,
})
.then((res) => res.data)
.catch((err) => err.response.data);
axios
.post(url, data, {
headers: {
Authorization: 'Bearer ' + token,
'Content-type': 'multipart/form-data'
},
onUploadProgress: onProgress
})
.then((res) => res.data)
.catch((err) => err.response.data);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const get = (url: string) =>
axios
.get(url)
.then((res) => res.data)
.catch((err) => err.response.data);
axios
.get(url)
.then((res) => res.data)
.catch((err) => err.response.data);
export const get_token = (url: string, token: string) =>
axios
.get(url, {
headers: { Authorization: "Bearer " + token },
})
.then((res) => res.data)
.catch((err) => err.response.data);
axios
.get(url, {
headers: { Authorization: 'Bearer ' + token }
})
.then((res) => res.data)
.catch((err) => err.response.data);
export const isErrorResponse = (
res: Responses.BaseResponse
): res is Responses.ErrorResponse => res.statusCode != 200;
export const isErrorResponse = (res: Responses.Base): res is Responses.Error =>
res.statusCode > 299;

View File

@@ -1,84 +1,130 @@
import type { Requests, Responses, UploadFile } from '@/dto';
import {
Responses,
Requests,
get_token,
post_token,
post_token_form,
isErrorResponse,
} from "./base";
get_token,
post_token,
post_token_form,
isErrorResponse
} from './base';
export const get_root = (
token: string
): Promise<Responses.FS.GetRootResponse | Responses.ErrorResponse> =>
get_token("/api/fs/root", token);
token: string
): Promise<Responses.GetRoot | Responses.Error> =>
get_token('/api/fs/root', token);
export const get_node = (
token: string,
node: number
): Promise<Responses.FS.GetNodeResponse | Responses.ErrorResponse> =>
get_token(`/api/fs/node/${node}`, token);
token: string,
node: number
): Promise<Responses.GetNode | Responses.Error> =>
get_token(`/api/fs/node/${node}`, token);
export const get_path = (
token: string,
node: number
): Promise<Responses.FS.GetPathResponse | Responses.ErrorResponse> =>
get_token(`/api/fs/path/${node}`, token);
token: string,
node: number
): Promise<Responses.GetPath | Responses.Error> =>
get_token(`/api/fs/path/${node}`, token);
export const create_folder = (
token: string,
parent: number,
name: string
): Promise<Responses.FS.CreateFolderResponse | Responses.ErrorResponse> =>
post_token<Requests.FS.CreateFolderRequest>(
"/api/fs/createFolder",
{
parent: parent,
name: name,
},
token
);
token: string,
parent: number,
name: string
): Promise<
Responses.CreateFolder | Responses.CreateFolderExists | Responses.Error
> =>
post_token<Requests.CreateFolder>(
'/api/fs/createFolder',
{
parent: parent,
name: name
},
token
);
export const create_file = (
token: string,
parent: number,
name: string
): Promise<Responses.FS.CreateFileResponse | Responses.ErrorResponse> =>
post_token<Requests.FS.CreateFileRequest>(
"/api/fs/createFile",
{
parent: parent,
name: name,
},
token
);
token: string,
parent: number,
name: string
): Promise<
Responses.CreateFolder | Responses.CreateFolderExists | Responses.Error
> =>
post_token<Requests.CreateFolder>(
'/api/fs/createFile',
{
parent: parent,
name: name
},
token
);
export const delete_node = (
token: string,
node: number
): Promise<Responses.FS.DeleteResponse | Responses.ErrorResponse> =>
post_token(`/api/fs/delete/${node}`, {}, token);
export const create_zip = (
token: string,
nodes: number[]
): Promise<Responses.CreateZip | Responses.Error> =>
post_token<Requests.CreateZip>(
'/api/fs/create_zip',
{
nodes: nodes
},
token
);
export const upload_file = async (
token: string,
parent: number,
file: File,
onProgress: (progressEvent: ProgressEvent) => void
): Promise<Responses.FS.UploadFileResponse | Responses.ErrorResponse> => {
const node = await create_file(token, parent, file.name);
if (isErrorResponse(node)) return node;
export const download_preview = (
token: string,
node: number
): Promise<Responses.DownloadBase64 | Responses.Error> =>
get_token(`/api/fs/download_preview/${node}`, token);
const form = new FormData();
form.set("file", file);
return post_token_form(`/api/fs/upload/${node.id}`, form, token, onProgress);
};
export const download_base64 = (
token: string,
node: number
): Promise<Responses.DownloadBase64 | Responses.Error> =>
get_token(`/api/fs/download_base64/${node}`, token);
export const get_type = (
token: string,
node: number
): Promise<Responses.GetType | Responses.Error> =>
get_token(`/api/fs/get_type/${node}`, token);
export async function upload_file(
token: string,
file: UploadFile,
onProgress: (progressEvent: ProgressEvent) => void
): Promise<Responses.Success | Responses.Error> {
const node = await create_file(token, file.parent, file.file.name);
if (isErrorResponse(node)) return node;
if ('exists' in node && !node.isFile)
return { statusCode: 400, message: 'File exists as folder' };
const form = new FormData();
form.set('file', file.file);
return post_token_form(
`/api/fs/upload/${node.id}`,
form,
token,
onProgress
);
}
export function download_file(token: string, id: number) {
const form = document.createElement("form");
form.method = "post";
form.target = "_blank";
form.action = "/api/fs/download";
form.innerHTML = `<input type="hidden" name="jwtToken" value="${token}"><input type="hidden" name="id" value="${id}">`;
document.body.appendChild(form);
form.submit();
document.body.removeChild(form);
const form = document.createElement('form');
form.method = 'post';
form.target = '_blank';
form.action = '/api/fs/download';
form.innerHTML = `<input type="hidden" name="jwtToken" value="${token}"><input type="hidden" name="id" value="${id}">`;
document.body.appendChild(form);
form.submit();
document.body.removeChild(form);
}
export function download_multi_file(token: string, ids: number[]) {
const form = document.createElement('form');
form.method = 'post';
form.target = '_blank';
form.action = '/api/fs/download_multi';
form.innerHTML = `<input type="hidden" name="jwtToken" value="${token}"><input type="hidden" name="id" value="${ids.join(
','
)}">`;
document.body.appendChild(form);
form.submit();
document.body.removeChild(form);
}

View File

@@ -1,6 +1,7 @@
export { Requests, Responses, UserRole, isErrorResponse } from "./base";
export * as Auth from "./auth";
export * as FS from "./fs";
export * as User from "./user";
export * as Admin from "./admin";
export * from "./util";
export type { Requests, Responses, UploadFile } from './base';
export { UserRole, isErrorResponse } from './base';
export * as Auth from './auth';
export * as FS from './fs';
export * as User from './user';
export * as Admin from './admin';
export * from './util';

View File

@@ -1,11 +1,12 @@
import { Responses, get_token, post_token } from "@/api/base";
import type { Responses } from '@/api/base';
import { get_token, post_token } from '@/api/base';
export const get_user_info = (
token: string
): Promise<Responses.User.UserInfoResponse | Responses.ErrorResponse> =>
get_token("/api/user/info", token);
token: string
): Promise<Responses.UserInfo | Responses.Error> =>
get_token('/api/user/info', token);
export const delete_user = (
token: string
): Promise<Responses.User.DeleteUserResponse | Responses.ErrorResponse> =>
post_token("/api/user/delete", {}, token);
token: string
): Promise<Responses.Success | Responses.Error> =>
post_token('/api/user/delete', {}, token);

View File

@@ -1,26 +1,26 @@
import type { JwtPayload } from "jwt-decode";
import type { Ref, UnwrapRef } from "vue";
import jwtDecode from "jwt-decode";
import { isErrorResponse } from "./base";
import { refresh_token } from "./auth";
import type { JwtPayload } from 'jwt-decode';
import type { Ref, UnwrapRef } from 'vue';
import jwtDecode from 'jwt-decode';
import { isErrorResponse } from './base';
import { refresh_token } from './auth';
export async function check_token(
token: TokenInjectType
token: TokenInjectType
): Promise<string | void> {
if (!token.jwt.value) return token.logout();
const payload = jwtDecode<JwtPayload>(token.jwt.value);
if (!payload) return token.logout();
// Expires in more than 60 Minute
if (payload.exp && payload.exp > Math.floor(Date.now() / 1000 + 60 * 60))
return token.jwt.value;
const new_token = await refresh_token(token.jwt.value);
if (isErrorResponse(new_token)) return token.logout();
token.setToken(new_token.jwt);
return new_token.jwt;
if (!token.jwt.value) return token.logout();
const payload = jwtDecode<JwtPayload>(token.jwt.value);
if (!payload) return token.logout();
// Expires in more than 60 Minute
if (payload.exp && payload.exp > Math.floor(Date.now() / 1000 + 60 * 60))
return token.jwt.value;
const new_token = await refresh_token(token.jwt.value);
if (isErrorResponse(new_token)) return token.logout();
token.setToken(new_token.jwt);
return new_token.jwt;
}
export type TokenInjectType = {
jwt: Ref<UnwrapRef<string | null>>;
setToken: (token: string) => void;
logout: () => void;
jwt: Ref<UnwrapRef<string | null>>;
setToken: (token: string) => void;
logout: () => void;
};

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69" xmlns:v="https://vecta.io/nano"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

Before

Width:  |  Height:  |  Size: 308 B

View File

@@ -0,0 +1,31 @@
<script setup async lang="ts">
import type { TokenInjectType } from '@/api';
import { inject, ref } from 'vue';
import { NImage } from 'naive-ui';
import { check_token, FS, isErrorResponse } from '@/api';
const props = defineProps<{
alt: string;
id: number;
}>();
const jwt = inject<TokenInjectType>('jwt') as TokenInjectType;
const success = ref(false);
const data = ref('');
const token = await check_token(jwt);
if (token) {
const resp = await FS.download_preview(jwt.jwt.value ?? '', props.id);
if (!isErrorResponse(resp)) {
data.value = resp.data;
success.value = true;
}
}
</script>
<template>
<NImage v-if="success" :alt="alt" :src="data" />
</template>
<style lang="scss"></style>

View File

@@ -0,0 +1,90 @@
import type { TokenInjectType } from '@/api';
import { ref } from 'vue';
import { NProgress, NButton, NIcon } from 'naive-ui';
import filesize from 'filesize';
import { Archive, Download } from '@vicons/carbon';
import { FS, check_token, isErrorResponse } from '@/api';
import type { DialogApiInjection } from 'naive-ui/es/dialog/src/DialogProvider';
export default function createZipDialog(
nodes: number[],
dialog: DialogApiInjection,
jwt: TokenInjectType
) {
const progress = ref(0);
const total = ref(1);
const percentage = ref(0);
const done = ref(false);
const dia = dialog.create({
title: 'Create Archive...',
closable: false,
closeOnEsc: false,
maskClosable: false,
icon: () => <Archive />,
content: () => (
<NProgress
type="line"
percentage={percentage.value}
height={20}
status="info"
showIndicator={false}
/>
),
action: () =>
done.value ? (
<NButton
onClick={async () => {
const token = await check_token(jwt);
if (!token) return;
if (nodes.length == 1)
FS.download_file(token, nodes[0]);
else FS.download_multi_file(token, nodes);
dia.destroy();
}}
>
{{
icon: () => (
<NIcon>
<Download />
</NIcon>
),
default: () => 'Download archive'
}}
</NButton>
) : (
<div>
{filesize(progress.value, {
base: 2,
standard: 'jedec'
})}
/
{filesize(total.value, {
base: 2,
standard: 'jedec'
})}
- {Math.floor(percentage.value * 1000) / 1000}%
</div>
)
});
let updateRunning = false;
const updateInterval = setInterval(async () => {
if (updateRunning) return;
updateRunning = true;
const token = await check_token(jwt);
if (!token) return;
const resp = await FS.create_zip(token, nodes);
if (isErrorResponse(resp)) return;
if (resp.done) {
percentage.value = 100;
clearInterval(updateInterval);
done.value = true;
} else {
progress.value = resp.progress ?? 0;
total.value = resp.total ?? 1;
if (total.value == 0) total.value = 1;
percentage.value = (progress.value / total.value) * 100;
}
updateRunning = false;
}, 500);
return dia;
}

View File

@@ -0,0 +1,74 @@
<script setup lang="ts">
import type { TokenInjectType } from '@/api';
import type { LogInst } from 'naive-ui';
import { ref, inject } from 'vue';
import { check_token } from '@/api';
import { NCard, NLog } from 'naive-ui';
const jwt = inject<TokenInjectType>('jwt') as TokenInjectType;
const log = ref('');
const logInst = ref<LogInst>();
function getLogWriter() {
const decoder = new TextDecoder();
return new WritableStream<Uint8Array>({
write(chunk) {
log.value += decoder.decode(chunk, { stream: true });
logInst.value?.scrollTo({ position: 'top' });
},
close() {
log.value += decoder.decode(new Uint8Array(0), { stream: false });
logInst.value?.scrollTo({ position: 'top' });
},
abort(err) {
log.value += `Error: ${err}\n`;
logInst.value?.scrollTo({ position: 'top' });
}
});
}
const props = defineProps<{
nodes: number[];
}>();
async function startDelete() {
const token = await check_token(jwt);
if (!token) return;
for (const node of props.nodes) {
try {
const logWriter = getLogWriter();
const resp = await fetch(`/api/fs/delete/${node}`, {
method: 'post',
headers: {
Authorization: 'Bearer ' + token
}
});
if (!resp.ok) continue;
if (!resp.body) continue;
await resp.body.pipeTo(logWriter);
} catch (err) {
log.value += `Error: ${err}\n`;
logInst.value?.scrollTo({ position: 'top' });
}
}
}
defineExpose({
startDelete
});
</script>
<template>
<n-card title="Deleting..." style="margin: 20px">
<n-log ref="logInst" class="log-code" :log="log" :rows="50"></n-log>
<!--<n-code class="log-code">
</n-code>-->
</n-card>
</template>
<style scoped lang="scss">
.log-code {
margin: 8px;
background-color: rgb(250, 250, 252);
}
</style>

View File

@@ -0,0 +1,124 @@
<script setup lang="tsx">
import type { TokenInjectType, Responses } from '@/api';
import type { CSSProperties } from 'vue';
import { inject, ref, watch } from 'vue';
import {
useMessage,
useDialog,
NSwitch,
NGrid,
NGi,
NButton,
NIcon,
NInput
} from 'naive-ui';
import { FolderAdd } from '@vicons/carbon';
import { FS, check_token } from '@/api';
import DirViewerTable from '@/components/DirViewer/DirViewerTable.vue';
import { loadingMsgWrapper } from '@/utils';
const message = useMessage();
const dialog = useDialog();
const props = defineProps<{
node: Responses.GetNode;
}>();
const jwt = inject<TokenInjectType>('jwt') as TokenInjectType;
const emit = defineEmits<{
(e: 'reloadNode'): void;
}>();
const showPreview = ref(false);
const nodes = ref<Responses.GetNodeEntry[]>([]);
watch(
() => props.node,
async (to) => {
nodes.value = [];
if (to.parent != null)
nodes.value.push({
id: to.parent,
isFile: false,
parent: null,
name: '..',
preview: false
});
if (to.children) nodes.value.push(...to.children);
},
{ immediate: true }
);
const newFolder = loadingMsgWrapper(message, async (name: string) => {
const token = await check_token(jwt);
if (!token) return;
await FS.create_folder(token, props.node.id, name);
emit('reloadNode');
});
function previewSwitchRailStyle(state: { focused: boolean; checked: boolean }) {
const style: CSSProperties = {};
style.background = state.checked ? '#0b0' : '#d00';
if (state.focused)
style.boxShadow = `0 0 0 2px ${
state.checked ? '#00bb0040' : '#dd000040'
}`;
return style;
}
function createNewFolderDialog() {
let newFolderName = '';
const dia = dialog.create({
title: 'New Folder',
icon: () => <FolderAdd />,
content: () => (
<NInput
type="text"
placeholder="Folder name"
onInput={(e) => (newFolderName = e)}
onKeyup={(e) => {
if (e.key === 'Enter')
newFolder(newFolderName).then(() => dia.destroy());
}}
/>
),
negativeText: 'Cancel',
positiveText: 'Create',
positiveButtonProps: { type: 'success' },
onPositiveClick: () => newFolder(newFolderName)
});
return dia;
}
</script>
<template>
<n-grid cols="2" x-gap="16" y-gap="16">
<n-gi>
<n-button @click="createNewFolderDialog">
<template #icon>
<n-icon><FolderAdd /></n-icon>
</template>
Create folder
</n-button>
</n-gi>
<n-gi style="text-align: right">
<n-switch
:rail-style="previewSwitchRailStyle"
v-model:value="showPreview"
>
<template #checked>Show preview</template>
<template #unchecked>Hide preview</template>
</n-switch>
</n-gi>
<n-gi span="2">
<DirViewerTable
:nodes="nodes"
:show-preview="showPreview"
@reloadNode="emit('reloadNode')"
/>
</n-gi>
</n-grid>
</template>
<style scoped></style>

View File

@@ -0,0 +1,379 @@
<script setup lang="tsx">
import type { TokenInjectType, Responses } from '@/api';
import type {
DropdownOption,
DropdownGroupOption,
DropdownDividerOption,
DropdownRenderOption,
DataTableColumn
} from 'naive-ui';
import type { SummaryCell } from 'naive-ui/es/data-table/src/interface';
import { inject, ref, nextTick, Suspense } from 'vue';
import filesize from 'filesize';
import { check_token, FS } from '@/api';
import { loadingMsgWrapper } from '@/utils';
import {
useMessage,
useDialog,
NDataTable,
NText,
NIcon,
NDropdown,
NPopover,
NSpin,
NImageGroup,
NButtonGroup,
NButton,
NModal
} from 'naive-ui';
import {
Folder,
FolderParent,
DocumentBlank,
Delete,
Download
} from '@vicons/carbon';
import NLink from '@/components/NLink.vue';
import AsyncImage from '@/components/AsyncImage.vue';
import createZipDialog from '@/components/DirViewer/CreateZipDialog';
import DeleteModal from '@/components/DirViewer/DeleteModal.vue';
const message = useMessage();
const dialog = useDialog();
const jwt = inject<TokenInjectType>('jwt') as TokenInjectType;
const emit = defineEmits<{
(e: 'reloadNode'): void;
}>();
type DropdownOptionsType = Array<
| DropdownOption
| DropdownGroupOption
| DropdownDividerOption
| DropdownRenderOption
>;
const props = defineProps<{
nodes: Responses.GetNodeEntry[];
showPreview: boolean;
}>();
const checkedRows = ref<number[]>([]);
const deleteNodes = ref<number[]>([]);
const deleteDialog = ref();
const deleteDialogShow = ref(false);
const dropdownX = ref(0);
const dropdownY = ref(0);
const dropdownShow = ref(false);
let dropdownCurrentNode: Responses.GetNodeEntry | null = null;
const dropdownOptions = ref<DropdownOptionsType>();
const dropdownOptionsFolder: DropdownOptionsType = [
{
label: () => <NText>Download</NText>,
key: 'download',
icon: () => (
<NIcon>
<Download />
</NIcon>
)
},
{
label: () => <NText type="error">Delete</NText>,
key: 'delete',
icon: () => (
<NIcon>
<Delete />
</NIcon>
)
}
];
const dropdownOptionsFile: DropdownOptionsType = [
{
label: () => <NText>Download</NText>,
key: 'download',
icon: () => (
<NIcon>
<Download />
</NIcon>
)
},
{
label: () => <NText type="error">Delete</NText>,
key: 'delete',
icon: () => (
<NIcon>
<Delete />
</NIcon>
)
}
];
const dropdownSelect = loadingMsgWrapper(message, async (key: string) => {
dropdownShow.value = false;
const token = await check_token(jwt);
if (!token) return;
if (!dropdownCurrentNode) return;
switch (key) {
case 'download':
if (dropdownCurrentNode.isFile)
await FS.download_file(token, dropdownCurrentNode.id);
else createZipDialog([dropdownCurrentNode.id], dialog, jwt);
break;
case 'delete':
dialog.warning({
title: 'Really delete?',
content: `Are you sure you want to delete "${dropdownCurrentNode.name}"`,
positiveText: 'Yes',
negativeText: 'No',
onPositiveClick: () => {
if (!dropdownCurrentNode) return;
deleteNodes.value = [dropdownCurrentNode.id];
showDeleteDialog();
}
});
break;
}
});
const columns: DataTableColumn<Responses.GetNodeEntry>[] = [
{
type: 'selection',
options: [
{
label: 'Select all folders',
key: 'folders',
onSelect(data) {
checkedRows.value = data
.filter((node) => !node.isFile)
.map((node) => node.id);
}
},
{
label: 'Select all files',
key: 'files',
onSelect(data) {
checkedRows.value = data
.filter((node) => node.isFile)
.map((node) => node.id);
}
}
],
disabled(node) {
return node.parent == null;
}
},
{
title: 'Name',
key: 'name',
minWidth: 720,
render(node) {
return (
<NLink to={`/fs/${node.id}`}>
<div>
<NIcon
size="1.2em"
color="#111"
component={
node.isFile
? DocumentBlank
: node.name == '..'
? FolderParent
: Folder
}
style="top: 0.25em; margin-right: 0.5em"
/>
{node.name}
</div>
</NLink>
);
}
},
{
title: 'Size',
key: 'size',
minWidth: 100,
render(node) {
return !node.isFile ? (
''
) : (
<NPopover trigger="hover">
{{
default: () => `${node.size?.toLocaleString()} bytes`,
trigger: () =>
filesize(node.size ?? 0, {
base: 2,
standard: 'jedec'
})
}}
</NPopover>
);
}
}
];
const previewColumns: DataTableColumn<Responses.GetNodeEntry>[] = [
columns[0],
{
title: 'Preview',
key: 'preview',
render(node) {
return node.preview ? (
<Suspense>
{{
default: () => (
<AsyncImage alt={node.name} id={node.id} />
),
fallback: () => <NSpin size="small" />
}}
</Suspense>
) : (
''
);
}
},
...columns.slice(1)
];
const massDownload = loadingMsgWrapper(message, async () => {
const token = await check_token(jwt);
if (!token) return;
const nodes = checkedRows.value;
if (nodes.length == 1) {
const node = props.nodes.find((n) => n.id == nodes[0]);
if (!node) return;
if (node.isFile) await FS.download_file(token, nodes[0]);
else createZipDialog(nodes, dialog, jwt);
} else createZipDialog(nodes, dialog, jwt);
checkedRows.value = [];
});
const massDelete = loadingMsgWrapper(message, async () => {
const token = await check_token(jwt);
if (!token) return;
dialog.warning({
title: 'Really delete?',
content: `Are you sure you want to delete "${checkedRows.value.length} folders/files"`,
positiveText: 'Yes',
negativeText: 'No',
onPositiveClick: loadingMsgWrapper(message, async () => {
deleteNodes.value = checkedRows.value;
showDeleteDialog();
})
});
});
const selectionCell = (): SummaryCell => {
return {
value:
checkedRows.value.length != 0 ? (
<NButtonGroup>
<NButton onClick={massDownload}>Download</NButton>
<NButton onClick={massDelete} type="error">
Delete
</NButton>
</NButtonGroup>
) : (
''
),
colSpan: props.showPreview ? 2 : 1
};
};
const sizeCell = (data: Responses.GetNodeEntry[]): SummaryCell => {
return {
value: (
<span>
{filesize(
data.reduce((cur, node) => cur + (node.size ?? 0), 0),
{
base: 2,
standard: 'jedec'
}
)}
</span>
)
};
};
function createPreviewSummary(data: Responses.GetNodeEntry[]) {
return {
preview: selectionCell(),
size: sizeCell(data)
};
}
function createSummary(data: Responses.GetNodeEntry[]) {
return {
name: selectionCell(),
size: sizeCell(data)
};
}
function rowProps(node: Responses.GetNodeEntry) {
if (!('isFile' in node)) return {};
return {
onContextmenu: (e: MouseEvent) => {
e.preventDefault();
dropdownShow.value = false;
dropdownCurrentNode = node;
dropdownOptions.value = node.isFile
? dropdownOptionsFile
: dropdownOptionsFolder;
nextTick().then(() => {
dropdownShow.value = true;
dropdownX.value = e.clientX;
dropdownY.value = e.clientY;
});
}
};
}
const rowKey = (node: Responses.GetNodeEntry): number => node.id;
function showDeleteDialog() {
if (deleteNodes.value.length == 0) return;
deleteDialogShow.value = true;
}
async function onShowDeleteDialog() {
await deleteDialog.value?.startDelete();
deleteDialogShow.value = false;
emit('reloadNode');
}
</script>
<template>
<n-image-group>
<n-data-table
:columns="showPreview ? previewColumns : columns"
:data="nodes"
:row-key="rowKey"
:row-props="rowProps"
:summary="showPreview ? createPreviewSummary : createSummary"
v-model:checked-row-keys="checkedRows"
/>
</n-image-group>
<n-dropdown
placement="bottom-start"
trigger="manual"
:x="dropdownX"
:y="dropdownY"
:show="dropdownShow"
:show-arrow="true"
:options="dropdownOptions"
:on-clickoutside="() => (dropdownShow = false)"
@select="dropdownSelect"
/>
<n-modal
v-model:show="deleteDialogShow"
:close-on-esc="false"
:mask-closable="false"
:on-after-enter="onShowDeleteDialog"
>
<DeleteModal ref="deleteDialog" :nodes="deleteNodes" />
</n-modal>
</template>
<style scoped></style>

View File

@@ -1,41 +0,0 @@
<script setup lang="ts">
import type { TokenInjectType } from "@/api";
import { defineEmits, defineProps, inject } from "vue";
import { check_token, FS, Responses } from "@/api";
const jwt = inject<TokenInjectType>("jwt") as TokenInjectType;
const props = defineProps<{
node: Responses.FS.GetNodeResponse;
}>();
const emit = defineEmits<{
(e: "reloadNode"): void;
}>();
async function del() {
const token = await check_token(jwt);
if (!token) return;
await FS.delete_node(token, props.node.id);
emit("reloadNode");
}
async function download() {
const token = await check_token(jwt);
if (!token) return;
FS.download_file(token, props.node.id);
}
</script>
<template>
<td>
<router-link :to="'/fs/' + props.node.id">{{ node.name }}</router-link>
</td>
<td>
<a href="#" @click="download()" v-if="props.node.isFile">Download</a>
</td>
<td>
<a href="#" @click="del()" v-if="props.node.name !== '..'">delete</a>
</td>
</template>
<style scoped></style>

View File

@@ -1,102 +0,0 @@
<script setup lang="ts">
import type { TokenInjectType } from "@/api";
import { defineEmits, defineProps, inject, reactive, ref, watch } from "vue";
import { FS, Responses, check_token } from "@/api";
import DirEntry from "@/components/FSView/DirEntry.vue";
import UploadFileDialog from "@/components/UploadDialog/UploadFileDialog.vue";
import { NModal } from "naive-ui";
const props = defineProps<{
node: Responses.FS.GetNodeResponse;
}>();
const jwt = inject<TokenInjectType>("jwt") as TokenInjectType;
const emit = defineEmits<{
(e: "reloadNode"): void;
(e: "gotoRoot"): void;
}>();
const fileInput = ref<HTMLInputElement>();
const uploadDialog = ref();
const uploadDialogShow = ref(false);
const new_folder_name = ref("");
const files = ref<File[]>([]);
const nodes = ref<Responses.FS.GetNodeResponse[]>([]);
const hasParent = ref(false);
const parentNode = reactive<Responses.FS.GetNodeResponse>({
id: 0,
statusCode: 200,
isFile: false,
parent: null,
name: "..",
});
watch(
() => props.node,
async (to) => {
parentNode.id = to.parent ?? 0;
hasParent.value = to.parent != null;
nodes.value = [];
const token = await check_token(jwt);
if (!token) return;
await Promise.all(
to.children?.map(async (child) => {
nodes.value.push(
(await FS.get_node(token, child)) as Responses.FS.GetNodeResponse
);
}) ?? []
);
},
{ immediate: true }
);
async function newFolder() {
const token = await check_token(jwt);
if (!token) return;
await FS.create_folder(token, props.node.id, new_folder_name.value);
emit("reloadNode");
}
async function uploadFiles() {
files.value = Array.from(fileInput.value?.files ?? []);
if (files.value.length == 0) return;
uploadDialogShow.value = true;
}
async function uploadFilesDialogOpen() {
await uploadDialog.value?.startUpload(props.node.id);
uploadDialogShow.value = false;
if (fileInput.value) fileInput.value.value = "";
emit("reloadNode");
}
</script>
<template>
<div>
<input type="text" placeholder="Folder name" v-model="new_folder_name" />
<a href="#" @click="newFolder()">create folder</a>
</div>
<div>
<input type="file" ref="fileInput" multiple />
<a href="#" @click="uploadFiles()">upload files</a>
</div>
<table>
<tr v-if="hasParent">
<DirEntry :node="parentNode" @reloadNode="emit('reloadNode')" />
</tr>
<tr v-for="n in nodes" :key="n.id">
<DirEntry :node="n" @reloadNode="emit('reloadNode')" />
</tr>
</table>
<n-modal
v-model:show="uploadDialogShow"
:close-on-esc="false"
:mask-closable="false"
:on-after-enter="uploadFilesDialogOpen"
>
<UploadFileDialog ref="uploadDialog" :files="files" />
</n-modal>
</template>
<style scoped></style>

View File

@@ -1,39 +0,0 @@
<script setup lang="ts">
import type { TokenInjectType } from "@/api";
import { defineProps, inject } from "vue";
import { check_token, FS, Responses } from "@/api";
const props = defineProps<{
node: Responses.FS.GetNodeResponse;
}>();
const jwt = inject<TokenInjectType>("jwt") as TokenInjectType;
async function del() {
const token = await check_token(jwt);
if (!token) return;
await FS.delete_node(token, props.node.id);
}
async function download() {
const token = await check_token(jwt);
if (!token) return;
FS.download_file(token, props.node.id);
}
</script>
<template>
<div>
<router-link :to="'/fs/' + props.node.parent ?? 0">..</router-link>
</div>
<div>
<a href="#" @click="download()" v-if="props.node.isFile">Download</a>
</div>
<div>
<router-link :to="'/fs/' + props.node.parent ?? 0" @click="del()">
delete
</router-link>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,45 @@
import { ref } from 'vue';
import { NProgress } from 'naive-ui';
import filesize from 'filesize';
import { Music, Video } from '@vicons/carbon';
import type { DialogApiInjection } from 'naive-ui/es/dialog/src/DialogProvider';
export default function createAudioVideoDialog(
dialog: DialogApiInjection,
video: boolean
) {
const progress = ref(0);
const total = ref(1);
const percentage = ref(0);
const dia = dialog.create({
title: video ? 'Loading video...' : 'Loading audio...',
closable: false,
closeOnEsc: false,
maskClosable: false,
icon: () => (video ? <Video /> : <Music />),
content: () => (
<NProgress
type="line"
percentage={percentage.value}
height={20}
status="info"
showIndicator={false}
/>
),
action: () => (
<div>
{filesize(progress.value, {
base: 2,
standard: 'jedec'
})}
/
{filesize(total.value, {
base: 2,
standard: 'jedec'
})}
- {Math.floor(percentage.value * 1000) / 1000}%
</div>
)
});
return { progress, total, percentage, dia };
}

View File

@@ -0,0 +1,129 @@
<script setup lang="ts">
import type { TokenInjectType, Responses } from '@/api';
import { inject, ref, watch } from 'vue';
import { Download, Play } from '@vicons/carbon';
import { useDialog, NGrid, NGi, NButton, NImage, NSpin, NIcon } from 'naive-ui';
import { check_token, FS, isErrorResponse } from '@/api';
import createAudioVideoDialog from '@/components/FileViewer/AudioVideoDownload';
import axios from 'axios';
const props = defineProps<{
node: Responses.GetNode;
}>();
const dialog = useDialog();
const jwt = inject<TokenInjectType>('jwt') as TokenInjectType;
enum fileTypes {
UNKNOWN,
LOADING,
IMAGE,
AUDIO,
VIDEO
}
const fileType = ref<fileTypes>(fileTypes.UNKNOWN);
const src = ref('');
async function download() {
const token = await check_token(jwt);
if (!token) return;
FS.download_file(token, props.node.id);
}
async function loadAudioOrVideo() {
const token = await check_token(jwt);
if (!token) return;
const { progress, total, percentage, dia } = createAudioVideoDialog(
dialog,
fileType.value === fileTypes.VIDEO
);
total.value = props.node.size ?? 1;
const params = new URLSearchParams();
params.append('jwtToken', token);
params.append('id', props.node.id.toString());
const resp = await axios.post('/api/fs/download', params, {
responseType: 'blob',
onDownloadProgress: (e: ProgressEvent) => {
progress.value = e.loaded;
percentage.value = (e.loaded / e.total) * 100;
}
});
dia.destroy();
if (resp.status != 200) return;
src.value = URL.createObjectURL(resp.data as Blob);
}
async function getType(node: Responses.GetNode) {
fileType.value = fileTypes.LOADING;
if (src.value.startsWith('blob')) URL.revokeObjectURL(src.value);
src.value = '';
const token = await check_token(jwt);
if (!token) return;
const resp = await FS.get_type(token, node.id);
if (isErrorResponse(resp)) return;
if (resp.type.startsWith('image')) {
const dataResp = await FS.download_base64(token, node.id);
if (isErrorResponse(dataResp)) return;
src.value = dataResp.data;
fileType.value = fileTypes.IMAGE;
}
if (resp.type.startsWith('audio')) fileType.value = fileTypes.AUDIO;
if (resp.type.startsWith('video')) fileType.value = fileTypes.VIDEO;
}
watch(
() => props.node,
async (to) => {
await getType(to);
if (fileType.value === fileTypes.LOADING)
fileType.value = fileTypes.UNKNOWN;
},
{ immediate: true }
);
</script>
<template>
<n-grid cols="1" x-gap="16" y-gap="16">
<n-gi style="text-align: right">
<n-button @click="download()">
<template #icon>
<n-icon><Download /></n-icon>
</template>
Download
</n-button>
</n-gi>
<n-gi style="text-align: center">
<n-spin v-if="fileType === fileTypes.LOADING" size="large" />
<n-image
v-else-if="fileType === fileTypes.IMAGE"
:src="src"
:alt="node.name"
/>
<template
v-else-if="
fileType === fileTypes.VIDEO || fileType === fileTypes.AUDIO
"
>
<video
v-if="fileType === fileTypes.VIDEO && src !== ''"
:src="src"
controls
/>
<audio
v-else-if="fileType === fileTypes.AUDIO && src !== ''"
:src="src"
controls
/>
<n-button v-else @click="loadAudioOrVideo">
<template #icon>
<n-icon><Play /></n-icon>
</template>
Load and play
</n-button>
</template>
</n-gi>
</n-grid>
</template>
<style scoped></style>

View File

@@ -1,140 +0,0 @@
<template>
<div class="hello">
<h1>{{ msg }}</h1>
<p>
For a guide and recipes on how to configure / customize this project,<br />
check out the
<a href="https://cli.vuejs.org" target="_blank" rel="noopener"
>vue-cli documentation</a
>.
</p>
<h3>Installed CLI Plugins</h3>
<ul>
<li>
<a
href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-babel"
target="_blank"
rel="noopener"
>babel</a
>
</li>
<li>
<a
href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-router"
target="_blank"
rel="noopener"
>router</a
>
</li>
<li>
<a
href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-vuex"
target="_blank"
rel="noopener"
>vuex</a
>
</li>
<li>
<a
href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-eslint"
target="_blank"
rel="noopener"
>eslint</a
>
</li>
<li>
<a
href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-typescript"
target="_blank"
rel="noopener"
>typescript</a
>
</li>
</ul>
<h3>Essential Links</h3>
<ul>
<li>
<a href="https://vuejs.org" target="_blank" rel="noopener">Core Docs</a>
</li>
<li>
<a href="https://forum.vuejs.org" target="_blank" rel="noopener"
>Forum</a
>
</li>
<li>
<a href="https://chat.vuejs.org" target="_blank" rel="noopener"
>Community Chat</a
>
</li>
<li>
<a href="https://twitter.com/vuejs" target="_blank" rel="noopener"
>Twitter</a
>
</li>
<li>
<a href="https://news.vuejs.org" target="_blank" rel="noopener">News</a>
</li>
</ul>
<h3>Ecosystem</h3>
<ul>
<li>
<a href="https://router.vuejs.org" target="_blank" rel="noopener"
>vue-router</a
>
</li>
<li>
<a href="https://vuex.vuejs.org" target="_blank" rel="noopener">vuex</a>
</li>
<li>
<a
href="https://github.com/vuejs/vue-devtools#vue-devtools"
target="_blank"
rel="noopener"
>vue-devtools</a
>
</li>
<li>
<a href="https://vue-loader.vuejs.org" target="_blank" rel="noopener"
>vue-loader</a
>
</li>
<li>
<a
href="https://github.com/vuejs/awesome-vue"
target="_blank"
rel="noopener"
>awesome-vue</a
>
</li>
</ul>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
export default defineComponent({
name: "HelloWorld",
props: {
msg: String,
},
});
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped lang="scss">
h3 {
margin: 40px 0 0;
}
ul {
list-style-type: none;
padding: 0;
}
li {
display: inline-block;
margin: 0 10px;
}
a {
color: #42b983;
}
</style>

View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
import { NA } from 'naive-ui';
defineProps<{
to: string;
}>();
</script>
<template>
<router-link :to="to" #="{ navigate, href }" custom>
<n-a :href="href" @click="navigate"><slot /></n-a>
</router-link>
</template>

View File

@@ -1,52 +1,93 @@
<script setup lang="ts">
import type { Status } from "naive-ui/es/progress/src/interface";
import { defineProps, defineExpose, ref } from "vue";
import { isErrorResponse, FS } from "@/api";
import { NProgress } from "naive-ui";
import filesize from "filesize";
import type { Status } from 'naive-ui/es/progress/src/interface';
import type { UploadFile } from '@/api';
import { ref } from 'vue';
import { isErrorResponse, FS } from '@/api';
import { NProgress } from 'naive-ui';
import filesize from 'filesize';
const props = defineProps<{
file: File;
file: UploadFile;
}>();
const progress = ref(0);
const percentage = ref(0);
const err = ref("");
const status = ref<Status>("info");
const err = ref('');
const status = ref<Status>('info');
const shown = ref(true);
async function startUpload(parent: number, token: string) {
const resp = await FS.upload_file(token, parent, props.file, (e) => {
progress.value = e.loaded;
percentage.value = (e.loaded / e.total) * 100;
});
percentage.value = 100;
if (isErrorResponse(resp)) {
err.value = resp.message ?? "Error";
status.value = "error";
} else status.value = "success";
async function startUpload(token: string, done: () => void) {
const resp = await FS.upload_file(token, props.file, (e) => {
progress.value = e.loaded;
percentage.value = (e.loaded / e.total) * 100;
if (e.loaded == e.total) done();
});
percentage.value = 100;
if (isErrorResponse(resp)) {
err.value = resp.message ?? 'Error';
status.value = 'error';
} else {
status.value = 'success';
shown.value = false;
}
}
defineExpose({
startUpload,
startUpload
});
</script>
<template>
<div v-if="percentage < 100">
{{ file.name }} - {{ filesize(progress) }} / {{ filesize(file.size) }} -
{{ Math.floor(percentage * 1000) / 1000 }}%
</div>
<div v-else-if="err !== ''">{{ file.name }} - Error: {{ err }}</div>
<div v-else>{{ file.name }} - Completed</div>
<n-progress
type="line"
:percentage="percentage"
:height="20"
:status="status"
border-radius="10px 0"
fill-border-radius="10px 0"
:show-indicator="false"
/>
<Transition name="slide-up">
<div class="container" v-show="shown">
<div v-if="percentage < 100">
{{ file.fullName }} -
{{
filesize(progress, {
base: 2,
standard: 'jedec'
})
}}
/
{{
filesize(file.file.size, {
base: 2,
standard: 'jedec'
})
}}
- {{ Math.floor(percentage * 1000) / 1000 }}%
</div>
<div v-else-if="err !== ''">
{{ file.fullName }} - Error: {{ err }}
</div>
<div v-else>{{ file.fullName }} - Completed</div>
<n-progress
type="line"
:percentage="percentage"
:height="20"
:status="status"
border-radius="10px 0"
fill-border-radius="10px 0"
:show-indicator="false"
/>
</div>
</Transition>
</template>
<style scoped></style>
<style scoped lang="scss">
.container {
height: 60px;
padding: 8px;
}
.slide-up-leave-active {
transition: all 2s ease-out;
}
.slide-up-leave-to {
height: 0;
padding: 0 8px;
opacity: 0;
transform: translateY(-60px);
}
</style>

View File

@@ -0,0 +1,203 @@
<script setup lang="ts">
import type { TokenInjectType, Responses, UploadFile } from '@/api';
import { inject, ref } from 'vue';
import { useMessage, NModal, NText, NIcon } from 'naive-ui';
import { CloudUpload } from '@vicons/carbon';
import { FS, check_token, isErrorResponse } from '@/api';
import UploadFileDialog from '@/components/UploadDialog/UploadFileDialog.vue';
import { loadingMsgWrapper } from '@/utils';
const message = useMessage();
const props = defineProps<{
node: Responses.GetNode;
}>();
const jwt = inject<TokenInjectType>('jwt') as TokenInjectType;
const emit = defineEmits<{
(e: 'reloadNode'): void;
}>();
const uploadArea = ref<HTMLDivElement>();
const fileInput = ref<HTMLInputElement>();
const uploadDialog = ref();
const uploadDialogShow = ref(false);
const files = ref<UploadFile[]>([]);
function startDrag() {
uploadArea.value?.classList.add('uploadActive');
}
function stopDrag() {
uploadArea.value?.classList.remove('uploadActive');
}
function openBrowser() {
fileInput.value?.click();
}
function browserChanged(event: InputEvent) {
files.value = Array.from(
(event.target as HTMLInputElement).files ?? []
).map((file) => {
return {
parent: props.node.id,
fullName: file.name,
file
};
});
uploadFiles();
}
interface FileSystemDirectoryReader {
readEntries(
successCallback: (entries: FileSystemEntry[]) => void,
errorCallback?: (err: DOMException) => void
): void;
}
interface FileSystemEntry {
readonly fullPath: string;
readonly isDirectory: boolean;
readonly isFile: boolean;
readonly name: string;
file(
successCallback: (file: File) => void,
errorCallback?: (err: DOMException) => void
): void;
createReader(): FileSystemDirectoryReader;
}
const asyncReadEntries = async (
reader: FileSystemDirectoryReader
): Promise<FileSystemEntry[]> =>
new Promise((resolve, reject) => reader.readEntries(resolve, reject));
const getFile = async (entry: FileSystemEntry): Promise<File> =>
new Promise((resolve, reject) => entry.file(resolve, reject));
async function processDirOrFile(
entry: FileSystemEntry,
parent: number,
token: string
) {
if (entry.isDirectory) {
const resp = await FS.create_folder(token, parent, entry.name);
if (isErrorResponse(resp)) return;
if ('exists' in resp && resp.isFile) return;
const reader = entry.createReader();
let entries = [];
do {
try {
entries = await asyncReadEntries(reader);
entries.forEach((e) => processDirOrFile(e, resp.id, token));
} catch {
break;
}
} while (entries.length != 0);
} else
files.value.push({
parent: parent,
fullName: entry.fullPath.slice(1),
file: await getFile(entry)
});
}
const filesDropped = loadingMsgWrapper(message, async (event: DragEvent) => {
stopDrag();
if (!event.dataTransfer) return;
const token = await check_token(jwt);
if (!token) return;
files.value = [];
for (const file of event.dataTransfer.items) {
const entry = file.webkitGetAsEntry();
if (entry)
await processDirOrFile(
entry as unknown as FileSystemEntry,
props.node.id,
token
);
}
uploadFiles();
});
function uploadFiles() {
if (files.value.length == 0) return;
uploadDialogShow.value = true;
}
async function uploadFilesDialogOpen() {
await uploadDialog.value?.startUpload();
uploadDialogShow.value = false;
if (fileInput.value) fileInput.value.value = '';
emit('reloadNode');
}
</script>
<template>
<div
class="uploadArea"
ref="uploadArea"
@drop.prevent
@dragenter.prevent
@dragover.prevent
@dragleave.prevent
@dragend.prevent
@click="openBrowser"
@drop="filesDropped"
@dragenter="startDrag"
@dragover="startDrag"
@dragleave="stopDrag"
@dragend="stopDrag"
>
<input type="file" ref="fileInput" multiple @input="browserChanged" />
<div>
<n-icon size="2em">
<CloudUpload />
</n-icon>
</div>
<n-text>
Click&nbsp;or&nbsp;drag&nbsp;here&nbsp;to&nbsp;upload&nbsp;files
</n-text>
</div>
<n-modal
v-model:show="uploadDialogShow"
:close-on-esc="false"
:mask-closable="false"
:on-after-enter="uploadFilesDialogOpen"
>
<UploadFileDialog ref="uploadDialog" :files="files" />
</n-modal>
</template>
<style scoped lang="scss">
.uploadArea {
border: 1px dashed #ddd;
border-radius: 3px;
cursor: pointer;
background-color: rgb(250, 250, 252);
text-align: center;
transition: border-color 250ms ease-out, background-color 250ms ease-out;
padding: 20px;
input {
display: block;
width: 0;
height: 0;
opacity: 0;
}
}
.uploadArea:hover {
border-color: #888;
}
.uploadActive {
background-color: rgb(240, 252, 240);
}
</style>

View File

@@ -1,44 +1,44 @@
<script setup lang="ts">
import type { TokenInjectType } from "@/api";
import { defineProps, defineExpose, ref, inject } from "vue";
import { check_token } from "@/api";
import UploadEntry from "@/components/UploadDialog/UploadEntry.vue";
import { NCard } from "naive-ui";
import type { TokenInjectType, UploadFile } from '@/api';
import { ref, inject } from 'vue';
import { check_token } from '@/api';
import UploadEntry from '@/components/UploadDialog/UploadEntry.vue';
import { NCard } from 'naive-ui';
const jwt = inject<TokenInjectType>("jwt") as TokenInjectType;
const jwt = inject<TokenInjectType>('jwt') as TokenInjectType;
const entries = ref<typeof UploadEntry[]>([]);
const done = ref(false);
let canCloseResolve: (value: unknown) => void = () => null;
const canClose = new Promise((r) => (canCloseResolve = r));
async function startUpload(parent: number) {
const token = await check_token(jwt);
if (!token) return;
await Promise.all(
entries.value.map((entry) => entry.startUpload(parent, token))
);
done.value = true;
await canClose;
async function startUpload() {
const token = await check_token(jwt);
if (!token) return;
const ents: typeof UploadEntry[] = entries.value;
const allProms: Promise<void>[] = [];
for (const entry of ents) {
await new Promise<void>((resolve) =>
allProms.push(entry.startUpload(token, resolve))
);
}
await Promise.all(allProms);
}
defineExpose({
startUpload,
startUpload
});
defineProps<{
files: File[];
files: UploadFile[];
}>();
</script>
<template>
<n-card title="Upload Files">
<div>
<UploadEntry v-for="f in files" :key="f.name" ref="entries" :file="f" />
</div>
<div>
<button v-if="done" @click="canCloseResolve(null)">Close</button>
</div>
</n-card>
<n-card title="Uploading files" style="margin: 20px">
<UploadEntry
v-for="f in files"
:key="f.file.name"
ref="entries"
:file="f"
/>
</n-card>
</template>
<style scoped></style>

View File

@@ -0,0 +1,73 @@
<script setup lang="ts">
import type { TokenInjectType } from '@/api';
import { inject, ref } from 'vue';
import { Auth, check_token, isErrorResponse } from '@/api';
import { useMessage, NInput, NGrid, NGi, NButton, NCard } from 'naive-ui';
import { loadingMsgWrapper } from '@/utils';
const message = useMessage();
const oldPw = ref('');
const newPw = ref('');
const newPw2 = ref('');
const jwt = inject<TokenInjectType>('jwt') as TokenInjectType;
const changePw = loadingMsgWrapper(message, async () => {
if (oldPw.value === '' || newPw.value === '' || newPw2.value === '') {
message.error('Password missing');
return;
}
if (newPw.value !== newPw2.value) {
message.error("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))
message.error(`Password change failed: ${res.message}`);
else jwt.logout();
});
function onKey(event: KeyboardEvent) {
if (event.key == 'Enter') changePw();
}
</script>
<template>
<n-card title="Change password" embedded>
<n-grid cols="1" x-gap="16" y-gap="16">
<n-gi>
<n-input
type="password"
placeholder="Old password"
v-model:value="oldPw"
@keyup="onKey"
/>
</n-gi>
<n-gi>
<n-input
type="password"
placeholder="New password"
v-model:value="newPw"
@keyup="onKey"
/>
</n-gi>
<n-gi>
<n-input
type="password"
placeholder="Repeat new password"
v-model:value="newPw2"
@keyup="onKey"
/>
</n-gi>
<n-gi>
<n-button type="info" @click="changePw">
Change password
</n-button>
</n-gi>
</n-grid>
</n-card>
</template>
<style scoped></style>

View File

@@ -1,8 +1,150 @@
export * as Requests from "./requests";
export * as Responses from "./responses";
export {
UserRole,
validateSync,
validateAsync,
validateAsyncInline,
} from "./utils";
export enum UserRole {
ADMIN = 2,
USER = 1,
DISABLED = 0
}
export interface UploadFile {
parent: number;
fullName: string;
file: File;
}
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace Requests {
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface Base {}
export interface Admin extends Base {
user: number;
}
export interface SetUserRole extends Admin {
role: UserRole;
}
export interface SignUp extends Base {
username: string;
password: string;
}
export interface Login extends SignUp {
otp?: string;
}
export interface TfaSetup extends Base {
mail: boolean;
}
export interface TfaComplete extends Base {
mail: boolean;
code: string;
}
export interface ChangePassword extends Base {
oldPassword: string;
newPassword: string;
}
export interface CreateFolder extends Base {
parent: number;
name: string;
}
export interface CreateZip extends Base {
nodes: number[];
}
}
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace Responses {
export interface Base {
statusCode: number;
}
export interface Success extends Base {
statusCode: 200;
}
export interface Error extends Base {
statusCode: 400 | 401 | 403;
message?: string;
}
export interface Login extends Success {
jwt: string;
}
export interface RequestsTotpTfa extends Success {
qrCode: string;
secret: string;
}
export interface GetRoot extends Success {
rootId: number;
}
export interface GetNodeEntry {
id: number;
name: string;
isFile: boolean;
preview: boolean;
parent: number | null;
size?: number;
}
export interface GetNode extends Success, GetNodeEntry {
children?: GetNodeEntry[];
}
export interface PathSegment {
path: string;
node?: number;
}
export interface GetPath extends Success {
segments: PathSegment[];
}
export interface CreateFolder extends Success {
id: number;
}
export interface CreateFolderExists extends Success {
exists: true;
id: number;
isFile: boolean;
}
export interface CreateZip extends Success {
done: boolean;
progress?: number;
total?: number;
}
export interface DownloadBase64 extends Success {
data: string;
}
export interface GetType extends Success {
type: string;
}
export interface UserInfo extends Success {
name: string;
gitlab: boolean;
tfaEnabled: boolean;
}
export interface GetUsersEntry {
id: number;
gitlab: boolean;
name: string;
role: UserRole;
tfaEnabled: boolean;
}
export interface GetUsers extends Success {
users: GetUsersEntry[];
}
}

View File

@@ -1,17 +0,0 @@
import { BaseRequest } from "./base";
import { IsEnum, IsNumber } from "class-validator";
import { UserRole } from "@/dto";
export class AdminRequest extends BaseRequest {
@IsNumber()
user: number;
}
export class SetUserRole extends AdminRequest {
@IsEnum(UserRole)
role: UserRole;
}
export class LogoutAll extends AdminRequest {}
export class DeleteUser extends AdminRequest {}
export class DisableTfa extends AdminRequest {}

View File

@@ -1,50 +0,0 @@
import { BaseRequest } from "./base";
import {
IsBoolean,
IsEmail,
IsNotEmpty,
IsOptional,
IsString,
} from "class-validator";
export class SignUpRequest extends BaseRequest {
@IsEmail()
username: string;
@IsNotEmpty()
@IsString()
password: string;
}
export class LoginRequest extends SignUpRequest {
@IsOptional()
@IsNotEmpty()
@IsString()
otp?: string;
}
export class TfaSetup extends BaseRequest {
@IsNotEmpty()
@IsBoolean()
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;
}

View File

@@ -1 +0,0 @@
export class BaseRequest {}

View File

@@ -1,14 +0,0 @@
import { BaseRequest } from "./base";
import { IsInt, IsNotEmpty, IsString, Min } from "class-validator";
export class CreateFolderRequest extends BaseRequest {
@IsInt()
@Min(1)
parent: number;
@IsNotEmpty()
@IsString()
name: string;
}
export class CreateFileRequest extends CreateFolderRequest {}

View File

@@ -1,4 +0,0 @@
export * from "./base";
export * as Auth from "./auth";
export * as FS from "./fs";
export * as Admin from "./admin";

View File

@@ -1,61 +0,0 @@
import { SuccessResponse } from "./base";
import {
IsArray,
IsBoolean,
IsEnum,
IsNotEmpty,
IsNumber,
IsString,
ValidateNested,
} from "class-validator";
import { UserRole, ValidateConstructor } from "../utils";
@ValidateConstructor
export class GetUsersEntry {
constructor(
id: number,
gitlab: boolean,
name: string,
role: UserRole,
tfaEnabled: boolean
) {
this.id = id;
this.gitlab = gitlab;
this.name = name;
this.role = role;
this.tfaEnabled = tfaEnabled;
}
@IsNumber()
id: number;
@IsBoolean()
gitlab: boolean;
@IsString()
@IsNotEmpty()
name: string;
@IsEnum(UserRole)
role: UserRole;
@IsBoolean()
tfaEnabled: boolean;
}
@ValidateConstructor
export class GetUsers extends SuccessResponse {
constructor(users: GetUsersEntry[]) {
super();
this.users = users;
}
@IsArray()
@ValidateNested({ each: true })
users: GetUsersEntry[];
}
export class LogoutAllUser extends SuccessResponse {}
export class DeleteUser extends SuccessResponse {}
export class SetUserRole extends SuccessResponse {}
export class DisableTfa extends SuccessResponse {}

View File

@@ -1,40 +0,0 @@
import { SuccessResponse } from "./base";
import { IsBase32, IsJWT, IsNotEmpty } from "class-validator";
import { ValidateConstructor } from "../utils";
@ValidateConstructor
export class LoginResponse extends SuccessResponse {
constructor(jwt: string) {
super();
this.jwt = jwt;
}
@IsNotEmpty()
@IsJWT()
jwt: string;
}
@ValidateConstructor
export class RequestTotpTfaResponse extends SuccessResponse {
constructor(qrCode: string, secret: string) {
super();
this.qrCode = qrCode;
this.secret = secret;
}
@IsNotEmpty()
qrCode: string;
@IsNotEmpty()
@IsBase32()
secret: string;
}
export class TfaRequiredResponse extends SuccessResponse {}
export class RemoveTfaResponse extends SuccessResponse {}
export class RequestEmailTfaResponse extends SuccessResponse {}
export class TfaCompletedResponse extends SuccessResponse {}
export class SignupResponse extends SuccessResponse {}
export class ChangePasswordResponse extends SuccessResponse {}
export class LogoutAllResponse extends SuccessResponse {}
export class RefreshResponse extends LoginResponse {}

View File

@@ -1,25 +0,0 @@
import { IsNumber, Max, Min } from "class-validator";
export class BaseResponse {
constructor(statusCode: number) {
this.statusCode = statusCode;
}
@IsNumber()
@Min(100)
@Max(599)
statusCode: number;
}
export class SuccessResponse extends BaseResponse {
constructor() {
super(200);
}
declare statusCode: 200;
}
export class ErrorResponse extends BaseResponse {
declare statusCode: 400 | 401 | 403;
message?: string;
}

View File

@@ -1,89 +0,0 @@
import { SuccessResponse } from "./base";
import {
IsBoolean,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Min,
} from "class-validator";
import { ValidateConstructor } from "../utils";
@ValidateConstructor
export class GetRootResponse extends SuccessResponse {
constructor(rootId: number) {
super();
this.rootId = rootId;
}
@IsInt()
@Min(1)
rootId: number;
}
export class GetNodeResponse extends SuccessResponse {
constructor(
id: number,
name: string,
isFile: boolean,
parent: number | null
) {
super();
this.id = id;
this.name = name;
this.isFile = isFile;
this.parent = parent;
}
@IsInt()
@Min(1)
id: number;
@IsString()
name: string;
@IsBoolean()
isFile: boolean;
@IsOptional()
@IsInt()
@Min(1)
parent: number | null;
@IsOptional()
@IsInt({ each: true })
@Min(1, { each: true })
children?: number[];
@IsOptional()
@IsInt()
@Min(0)
size?: number;
}
@ValidateConstructor
export class GetPathResponse extends SuccessResponse {
constructor(path: string) {
super();
this.path = path;
}
@IsNotEmpty()
@IsString()
path: string;
}
@ValidateConstructor
export class CreateFolderResponse extends SuccessResponse {
constructor(id: number) {
super();
this.id = id;
}
@IsInt()
@Min(1)
id: number;
}
export class UploadFileResponse extends SuccessResponse {}
export class DeleteResponse extends SuccessResponse {}
export class CreateFileResponse extends CreateFolderResponse {}

View File

@@ -1,5 +0,0 @@
export * from "./base";
export * as Auth from "./auth";
export * as FS from "./fs";
export * as User from "./user";
export * as Admin from "./admin";

View File

@@ -1,27 +0,0 @@
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 {}

View File

@@ -1,43 +0,0 @@
import { validate, validateSync as _validateSync } from "class-validator";
export enum UserRole {
ADMIN = 2,
USER = 1,
DISABLED = 0,
}
export function validateSync<T extends object>(data: T): void {
const errors = _validateSync(data);
if (errors.length > 0) {
console.error("Validation failed, errors: ", errors);
throw new Error("Validation failed");
}
}
export async function validateAsync<T extends object>(data: T): Promise<void> {
const errors = await validate(data);
if (errors.length > 0) {
console.error("Validation failed, errors: ", errors);
throw new Error("Validation failed");
}
}
export async function validateAsyncInline<T extends object>(
data: T
): Promise<T> {
await validateAsync(data);
return data;
}
export function ValidateConstructor<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
T extends { new (...args: any[]): any }
>(constr: T) {
return class extends constr {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
constructor(...args: any[]) {
super(...args);
validateSync(this);
}
};
}

View File

@@ -1,8 +1,8 @@
import { createApp } from "vue";
import AppAsyncWrapper from "./AppAsyncWrapper.vue";
import router from "./router";
import { createApp } from 'vue';
import AppAsyncWrapper from './AppAsyncWrapper.vue';
import router from './router';
const app = createApp(AppAsyncWrapper);
app.use(router);
app.config.unwrapInjectedRef = true;
app.mount("#app");
app.mount('#app');

View File

@@ -1,64 +1,58 @@
import type { RouteRecordRaw } from "vue-router";
import { createRouter, createWebHistory } from "vue-router";
import LoginView from "@/views/LoginView.vue";
import SignupView from "@/views/SignupView.vue";
import HomeView from "@/views/HomeView.vue";
import AboutView from "@/views/AboutView.vue";
import FSView from "@/views/FSView.vue";
import SetTokenView from "@/views/SetTokenView.vue";
import ProfileView from "@/views/ProfileView.vue";
import TFAView from "@/views/TFAView.vue";
import AdminView from "@/views/AdminView.vue";
import type { RouteRecordRaw } from 'vue-router';
import { createRouter, createWebHistory } from 'vue-router';
import LoginView from '@/views/LoginView.vue';
import SignupView from '@/views/SignupView.vue';
import HomeView from '@/views/HomeView.vue';
import FSView from '@/views/FSView.vue';
import SetTokenView from '@/views/SetTokenView.vue';
import ProfileView from '@/views/ProfileView.vue';
import TFAView from '@/views/TFAView.vue';
import AdminView from '@/views/AdminView.vue';
const routes: Array<RouteRecordRaw> = [
{
path: "/",
name: "home",
component: HomeView,
},
{
path: "/profile",
name: "profile",
component: ProfileView,
},
{
path: "/profile/2fa-enable",
name: "2fa",
component: TFAView,
},
{
path: "/admin",
component: AdminView,
},
{
path: "/about",
component: AboutView,
},
{
path: "/login",
name: "login",
component: LoginView,
},
{
path: "/signup",
name: "signup",
component: SignupView,
},
{
path: "/fs/:node_id",
name: "fs",
component: FSView,
},
{
path: "/set_token",
component: SetTokenView,
},
{
path: '/',
name: 'home',
component: HomeView
},
{
path: '/profile',
name: 'profile',
component: ProfileView
},
{
path: '/profile/2fa-enable',
name: '2fa',
component: TFAView
},
{
path: '/admin',
component: AdminView
},
{
path: '/login',
name: 'login',
component: LoginView
},
{
path: '/signup',
name: 'signup',
component: SignupView
},
{
path: '/fs/:node_id',
name: 'fs',
component: FSView
},
{
path: '/set_token',
component: SetTokenView
}
];
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
history: createWebHistory(import.meta.env.BASE_URL),
routes
});
export default router;

19
frontend/src/utils.ts Normal file
View File

@@ -0,0 +1,19 @@
import type { MessageApiInjection } from 'naive-ui/es/message/src/MessageProvider';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function loadingMsgWrapper<T extends (...args: any[]) => Promise<any>>(
msg: MessageApiInjection,
func: T
): T {
return <T>(async (...args: never[]) => {
const loadMsg = msg.loading('Working', {
duration: 0,
closable: false
});
try {
return await func(...args);
} finally {
loadMsg.destroy();
}
});
}

View File

@@ -1,5 +0,0 @@
<template>
<div class="about">
<h1>This is an about page</h1>
</div>
</template>

View File

@@ -1,104 +1,152 @@
<script setup lang="ts">
import type { TokenInjectType } from "@/api";
import { inject, onBeforeMount, ref } from "vue";
import { Responses, check_token, Admin, isErrorResponse } from "@/api";
import { onBeforeRouteUpdate } from "vue-router";
import router from "@/router";
<script setup lang="tsx">
import type { TokenInjectType, Responses } from '@/api';
import type { SelectOption, DataTableColumn } from 'naive-ui';
import { inject, onBeforeMount, ref } from 'vue';
import { check_token, Admin, isErrorResponse } from '@/api';
import { onBeforeRouteUpdate } from 'vue-router';
import router from '@/router';
import { loadingMsgWrapper } from '@/utils';
import {
useMessage,
NDataTable,
NSelect,
NButton,
NButtonGroup
} from 'naive-ui';
const jwt = inject<TokenInjectType>("jwt") as TokenInjectType;
const message = useMessage();
const users = ref<Responses.Admin.GetUsersEntry[]>([]);
const jwt = inject<TokenInjectType>('jwt') as TokenInjectType;
const users = ref<Responses.GetUsersEntry[]>([]);
onBeforeRouteUpdate(async () => {
await updatePanel();
await updatePanel();
});
onBeforeMount(async () => {
await updatePanel();
await updatePanel();
});
async function updatePanel() {
const token = await check_token(jwt);
if (!token) return;
const res = await Admin.get_users(token);
if (isErrorResponse(res)) return router.replace({ path: "/" });
users.value = res.users;
}
const updatePanel = loadingMsgWrapper(message, async () => {
const token = await check_token(jwt);
if (!token) return;
async function setRole(user: number, roleStr: string) {
const token = await check_token(jwt);
if (!token) return;
const res = await Admin.get_users(token);
if (isErrorResponse(res)) return router.replace({ path: '/' });
users.value = res.users;
});
const res = await Admin.set_role(user, parseInt(roleStr, 10), token);
if (isErrorResponse(res)) console.error(res.message);
await updatePanel();
}
const setRole = loadingMsgWrapper(
message,
async (user: number, role: number) => {
const token = await check_token(jwt);
if (!token) return;
async function disableTfa(user: number) {
const token = await check_token(jwt);
if (!token) return;
const res = await Admin.set_role(user, role, token);
if (isErrorResponse(res)) console.error(res.message);
await updatePanel();
}
);
const res = await Admin.disable_tfa(user, token);
if (isErrorResponse(res)) console.error(res.message);
await updatePanel();
}
const action = (
func: (
user: number,
token: string
) => Promise<Responses.Success | Responses.Error>
) => {
return loadingMsgWrapper(message, async (user: number) => {
const token = await check_token(jwt);
if (!token) return;
async function logoutUser(user: number) {
const token = await check_token(jwt);
if (!token) return;
const res = await func(user, token);
if (isErrorResponse(res)) console.error(res.message);
await updatePanel();
});
};
const res = await Admin.logout(user, token);
if (isErrorResponse(res)) console.error(res.message);
await updatePanel();
}
const logoutUser = action(Admin.logout);
const disableTfa = action(Admin.disable_tfa);
const deleteUser = action(Admin.delete_user);
async function deleteUser(user: number) {
const token = await check_token(jwt);
if (!token) return;
const selectOptions: SelectOption[] = [
{
label: 'Disabled',
value: 0
},
{
label: 'User',
value: 1
},
{
label: 'Admin',
value: 2
}
];
const res = await Admin.delete_user(user, token);
if (isErrorResponse(res)) console.error(res.message);
await updatePanel();
}
const columns: DataTableColumn<Responses.GetUsersEntry>[] = [
{
title: 'Name',
key: 'name'
},
{
title: 'Type',
key: 'gitlab',
render(user) {
return user.gitlab ? 'Gitlab' : 'Password';
}
},
{
title: 'Role',
key: 'role',
minWidth: 120,
render(user) {
return (
<NSelect
value={user.role}
options={selectOptions}
onUpdateValue={(value: number) => setRole(user.id, value)}
/>
);
}
},
{
title: 'Tfa Status',
key: 'tfaEnabled',
render(user) {
return user.gitlab ? '' : user.tfaEnabled ? 'Enabled' : 'Disabled';
}
},
{
title: 'Actions',
key: 'actions',
render(user) {
return (
<NButtonGroup>
<NButton onClick={() => logoutUser(user.id)}>
Logout all
</NButton>
{user.tfaEnabled ? (
<NButton
type="warning"
onClick={() => disableTfa(user.id)}
>
Disable Tfa
</NButton>
) : (
''
)}
<NButton onClick={() => deleteUser(user.id)} type="error">
Delete
</NButton>
</NButtonGroup>
);
}
}
];
</script>
<template>
<table>
<tr>
<th>Name</th>
<th>Type</th>
<th>Role</th>
<th>Tfa Status</th>
<th>Actions</th>
</tr>
<tr v-for="user in users" :key="user.id">
<td>{{ user.name }}</td>
<td>{{ user.gitlab ? "Gitlab" : "Password" }}</td>
<td>
<select @change="setRole(user.id, ($event.target as HTMLSelectElement).value)">
<option value="0" :selected="user.role === 0 ? true : undefined">
Disabled
</option>
<option value="1" :selected="user.role === 1 ? true : undefined">
User
</option>
<option value="2" :selected="user.role === 2 ? true : undefined">
Admin
</option>
</select>
</td>
<td v-if="user.gitlab"></td>
<td v-else>
{{ user.tfaEnabled ? "Enabled" : "Disabled" }}
</td>
<td>
<button v-if="user.tfaEnabled" @click="disableTfa(user.id)">
Disable Tfa
</button>
<button @click="logoutUser(user.id)">Logout all</button>
<button @click="deleteUser(user.id)">Delete</button>
</td>
</tr>
</table>
<n-data-table :columns="columns" :data="users" />
</template>
<style scoped></style>

View File

@@ -1,65 +1,92 @@
<script setup lang="ts">
import type { TokenInjectType } from "@/api";
import { onBeforeRouteUpdate, useRoute, useRouter } from "vue-router";
import { inject, onBeforeMount, ref } from "vue";
import { check_token, FS, Responses, isErrorResponse } from "@/api";
import DirViewer from "@/components/FSView/DirViewer.vue";
import FileViewer from "@/components/FSView/FileViewer.vue";
import type { TokenInjectType, Responses } from '@/api';
import { onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router';
import { inject, onBeforeMount, ref } from 'vue';
import { NCard } from 'naive-ui';
import { check_token, FS, isErrorResponse } from '@/api';
import UploadField from '@/components/UploadDialog/UploadField.vue';
import DirViewer from '@/components/DirViewer/DirViewer.vue';
import FileViewer from '@/components/FileViewer/FileViewer.vue';
import NLink from '@/components/NLink.vue';
const router = useRouter();
const route = useRoute();
const jwt = inject<TokenInjectType>("jwt") as TokenInjectType;
const jwt = inject<TokenInjectType>('jwt') as TokenInjectType;
const path = ref("");
const node = ref<Responses.FS.GetNodeResponse | null>(null);
const path = ref<Responses.GetPath | null>(null);
const node = ref<Responses.GetNode | null>(null);
function nameCompare(a: Responses.GetNodeEntry, b: Responses.GetNodeEntry) {
const aStr = a.name.toLowerCase();
const bStr = b.name.toLowerCase();
return aStr.localeCompare(bStr);
}
async function fetch_node(node_id: number) {
const token = await check_token(jwt);
if (!token) return;
const [p, n] = [
await FS.get_path(token, node_id),
await FS.get_node(token, node_id),
];
if (isErrorResponse(p)) return gotoRoot();
if (isErrorResponse(n)) return gotoRoot();
[path.value, node.value] = [p.path, n];
const token = await check_token(jwt);
if (!token) return;
const [p, n] = [
await FS.get_path(token, node_id),
await FS.get_node(token, node_id)
];
if (isErrorResponse(p)) return gotoRoot();
if (isErrorResponse(n)) return gotoRoot();
if (n.children) {
const folders = n.children
.filter((node) => !node.isFile)
.sort(nameCompare);
const files = n.children
.filter((node) => node.isFile)
.sort(nameCompare);
n.children = [...folders, ...files];
}
[path.value, node.value] = [p, n];
}
onBeforeRouteUpdate(async (to) => {
await fetch_node(Number(to.params.node_id));
await fetch_node(Number(to.params.node_id));
});
async function reloadNode() {
await fetch_node(Number(route.params.node_id));
await fetch_node(Number(route.params.node_id));
}
onBeforeMount(async () => {
await reloadNode();
await reloadNode();
});
async function gotoRoot() {
const token = await check_token(jwt);
if (!token) return;
const rootRes = await FS.get_root(token);
if (isErrorResponse(rootRes)) return jwt.logout();
const root = rootRes.rootId;
await router.replace({
name: "fs",
params: { node_id: root },
});
const token = await check_token(jwt);
if (!token) return;
const rootRes = await FS.get_root(token);
if (isErrorResponse(rootRes)) return jwt.logout();
const root = rootRes.rootId;
await router.replace({
name: 'fs',
params: { node_id: root }
});
}
</script>
<template>
<div v-if="node">
<div>Path: {{ path }}</div>
<DirViewer
v-if="!node.isFile"
:node="node"
@reloadNode="reloadNode"
@gotoRoot="gotoRoot"
/>
<FileViewer v-else :node="node" />
</div>
<n-card v-if="node" header-style="font-size: 1.5em">
<template #header>
<span
v-for="seg in path?.segments ?? []"
:key="seg.path"
style="margin-left: 0.25em"
>
<NLink v-if="seg.node" :to="`/fs/${seg.node}`">
{{ seg.path }}
</NLink>
<template v-else>{{ seg.path }}</template>
</span>
</template>
<template v-if="!node.isFile" #header-extra>
<UploadField :node="node" @reloadNode="reloadNode" />
</template>
<DirViewer v-if="!node.isFile" :node="node" @reloadNode="reloadNode" />
<FileViewer v-else :node="node" />
</n-card>
</template>
<style scoped></style>

View File

@@ -1,29 +1,29 @@
<template><p></p></template>
<script setup lang="ts">
import type { TokenInjectType } from "@/api";
import { onBeforeRouteUpdate, useRouter } from "vue-router";
import { inject, onBeforeMount } from "vue";
import { FS, check_token, isErrorResponse } from "@/api";
import type { TokenInjectType } from '@/api';
import { onBeforeRouteUpdate, useRouter } from 'vue-router';
import { inject, onBeforeMount } from 'vue';
import { FS, check_token, isErrorResponse } from '@/api';
const router = useRouter();
const jwt = inject<TokenInjectType>("jwt") as TokenInjectType;
const jwt = inject<TokenInjectType>('jwt') as TokenInjectType;
async function start_redirect() {
const token = await check_token(jwt);
if (!token) return;
const root = await FS.get_root(token);
if (isErrorResponse(root)) return jwt.logout();
await router.replace({
name: "fs",
params: { node_id: root.rootId },
});
const token = await check_token(jwt);
if (!token) return;
const root = await FS.get_root(token);
if (isErrorResponse(root)) return jwt.logout();
await router.replace({
name: 'fs',
params: { node_id: root.rootId }
});
}
onBeforeRouteUpdate(async () => {
await start_redirect();
await start_redirect();
});
onBeforeMount(async () => {
await start_redirect();
await start_redirect();
});
</script>

View File

@@ -1,62 +1,142 @@
<script setup lang="ts">
import type { TokenInjectType } from "@/api";
import { ref, inject } from "vue";
import { Auth, FS, isErrorResponse } from "@/api";
import { useRouter } from "vue-router";
import type { TokenInjectType } from '@/api';
import { ref, inject } from 'vue';
import { Auth, FS, isErrorResponse } from '@/api';
import { useRouter } from 'vue-router';
import {
useMessage,
NInput,
NGrid,
NGi,
NButton,
NIcon,
NH4,
NCard
} from 'naive-ui';
import { LogoGitlab } from '@vicons/ionicons5';
import { loadingMsgWrapper } from '@/utils';
const router = useRouter();
const message = useMessage();
const username = ref("");
const password = ref("");
const otp = ref("");
const error = ref("");
const username = ref('');
const password = ref('');
const otp = ref('');
const requestOtp = ref(false);
const jwt = inject<TokenInjectType>("jwt") as TokenInjectType;
const jwt = inject<TokenInjectType>('jwt') as TokenInjectType;
async function login() {
error.value = "";
if (username.value === "" || password.value === "") {
error.value = "Email and/or Password missing";
return;
}
const res = await (requestOtp.value
? Auth.auth_login(username.value, password.value, otp.value)
: Auth.auth_login(username.value, password.value));
if (isErrorResponse(res)) error.value = "Login failed: " + res.message;
else if ("jwt" in res) {
const root = await FS.get_root(res.jwt);
if (isErrorResponse(root)) {
error.value = "Get root failed: " + root.message;
return;
}
jwt.setToken(res.jwt);
await router.push({
name: "fs",
params: { node_id: root.rootId },
});
} else {
error.value = "";
requestOtp.value = true;
}
const login = loadingMsgWrapper(message, async () => {
if (username.value === '' || password.value === '') {
message.error('Email and/or Password missing', {
closable: true,
duration: 5000
});
return;
}
const res = await (requestOtp.value
? Auth.auth_login(username.value, password.value, otp.value)
: Auth.auth_login(username.value, password.value));
if (isErrorResponse(res)) {
message.error(`Login failed: ${res.message}`, {
closable: true,
duration: 5000
});
} else if ('jwt' in res) {
const root = await FS.get_root(res.jwt);
if (isErrorResponse(root)) {
message.error(`Get root failed: ${root.message}`, {
closable: true,
duration: 5000
});
return;
}
jwt.setToken(res.jwt);
await router.push({
name: 'fs',
params: { node_id: root.rootId }
});
} else requestOtp.value = true;
});
function loginGitlab() {
window.location.pathname = '/api/auth/gitlab';
}
function signup() {
router.replace('signup');
}
function onKey(event: KeyboardEvent) {
if (event.key == 'Enter') login();
}
</script>
<template>
<div v-if="error !== ''" v-text="error"></div>
<template v-if="!requestOtp">
<input type="email" placeholder="Email" v-model="username" />
<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 v-else>
<div>Please input your 2 factor authentication code</div>
<input type="text" placeholder="Code" v-model="otp" />
</template>
<button @click="login()">Login</button>
<n-card>
<template v-if="!requestOtp">
<n-grid cols="2" x-gap="16" y-gap="16">
<n-gi span="2">
<n-input
type="text"
placeholder="Email"
v-model:value="username"
autofocus
:input-props="{ type: 'email' }"
@keyup="onKey"
/>
</n-gi>
<n-gi span="2">
<n-input
type="password"
placeholder="Password"
v-model:value="password"
@keyup="onKey"
/>
</n-gi>
<n-gi span="2" style="text-align: center">
<n-button type="info" @click="login">Login</n-button>
</n-gi>
<n-gi>
<n-button
ghost
color="#fc6d27"
text-color="#000"
@click="loginGitlab"
>
<template #icon>
<n-icon color="#fc6d27"><LogoGitlab /></n-icon>
</template>
Login with gitlab
</n-button>
</n-gi>
<n-gi style="text-align: right">
<n-button ghost @click="signup">Signup</n-button>
</n-gi>
</n-grid>
</template>
<template v-else>
<n-grid cols="2" x-gap="16" y-gap="16">
<n-gi span="2" style="text-align: center">
<n-h4>Please input your 2 factor authentication code</n-h4>
</n-gi>
<n-gi span="1">
<n-input
type="text"
placeholder="Code"
maxlength="6"
v-model:value="otp"
autofocus
@keyup="onKey"
/>
</n-gi>
<n-gi span="1" style="text-align: right">
<n-button type="info" @click="login">Login</n-button>
</n-gi>
</n-grid>
</template>
</n-card>
</template>
<style scoped></style>

View File

@@ -1,106 +1,108 @@
<script setup lang="ts">
import type { TokenInjectType } from "@/api";
import { ref, inject, onBeforeMount } from "vue";
import { Auth, User, check_token, isErrorResponse, Responses } from "@/api";
import { onBeforeRouteUpdate } from "vue-router";
import type { TokenInjectType, Responses } from '@/api';
import { ref, inject, onBeforeMount } from 'vue';
import { Auth, User, check_token, isErrorResponse } from '@/api';
import { onBeforeRouteUpdate, useRouter } from 'vue-router';
import { NSpin, NGrid, NGi, NButton, NCard, useMessage } from 'naive-ui';
import UserChangePw from '@/components/UserChangePw.vue';
import { loadingMsgWrapper } from '@/utils';
const error = ref("");
const oldPw = ref("");
const newPw = ref("");
const newPw2 = ref("");
const user = ref<Responses.User.UserInfoResponse | null>(null);
const router = useRouter();
const message = useMessage();
const jwt = inject<TokenInjectType>("jwt") as TokenInjectType;
const user = ref<Responses.UserInfo | null>(null);
const jwt = inject<TokenInjectType>('jwt') as TokenInjectType;
onBeforeRouteUpdate(async () => {
await updateProfile();
await updateProfile();
});
onBeforeMount(async () => {
await updateProfile();
await updateProfile();
});
async function updateProfile() {
const token = await check_token(jwt);
if (!token) return;
const updateProfile = loadingMsgWrapper(message, async () => {
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;
}
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();
}
const deleteUser = loadingMsgWrapper(message, async () => {
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();
}
const logoutAll = loadingMsgWrapper(message, async () => {
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();
}
const tfaDisable = loadingMsgWrapper(message, async () => {
const token = await check_token(jwt);
if (!token) return;
await Auth.tfa_disable(token);
jwt.logout();
});
async function tfaDisable() {
const token = await check_token(jwt);
if (!token) return;
await Auth.tfa_disable(token);
jwt.logout();
async function tfaEnable() {
await router.push('/profile/2fa-enable');
}
</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 v-if="user">
<n-card :title="user.name">
<n-grid cols="2" x-gap="16" y-gap="16">
<template v-if="!user.gitlab">
<n-gi span="2">
<n-grid cols="2" x-gap="16">
<n-gi><UserChangePw /></n-gi>
<n-gi>
<n-card
title="2 Factor authentication"
embedded
>
<n-button
v-if="user.tfaEnabled"
type="error"
@click="tfaDisable"
>
Disable
</n-button>
<n-button
v-else
type="success"
@click="tfaEnable"
>
Enable
</n-button>
</n-card>
</n-gi>
</n-grid>
</n-gi>
</template>
<n-gi>
<n-button type="error" @click="logoutAll">
Logout everywhere
</n-button>
</n-gi>
<n-gi>
<n-button type="error" @click="deleteUser">
Delete Account
</n-button>
</n-gi>
</n-grid>
</n-card>
</template>
<template v-else>
<div><n-spin size="small" />Loading...</div>
</template>
</template>
<style scoped></style>

View File

@@ -1,19 +1,19 @@
<script setup lang="ts">
import type { TokenInjectType } from "@/api";
import { inject } from "vue";
import { useRoute, useRouter } from "vue-router";
import type { TokenInjectType } from '@/api';
import { inject } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const router = useRouter();
const route = useRoute();
const jwt = inject<TokenInjectType>("jwt") as TokenInjectType;
const jwt = inject<TokenInjectType>('jwt') as TokenInjectType;
if ("token" in route.query) jwt.setToken(route.query["token"] as string);
router.replace({ path: "/" });
if ('token' in route.query) jwt.setToken(route.query['token'] as string);
router.replace({ path: '/' });
</script>
<template>
<router-link to="/">Click here to go home</router-link>
<router-link to="/" replace>Click here to go home</router-link>
</template>
<style scoped></style>

View File

@@ -1,35 +1,85 @@
<script setup lang="ts">
import { ref } from "vue";
import { Auth, isErrorResponse } from "@/api";
import { ref } from 'vue';
import { Auth, isErrorResponse } from '@/api';
import { useMessage, NInput, NGrid, NGi, NButton, NCard } from 'naive-ui';
import { useRouter } from 'vue-router';
import { loadingMsgWrapper } from '@/utils';
const username = ref("");
const password = ref("");
const password2 = ref("");
const error = ref("");
const router = useRouter();
const message = useMessage();
async function signup() {
if (username.value === "" || password.value === "") {
error.value = "Email and/or Password missing";
return;
}
if (password.value !== password2.value) {
error.value = "Passwords don't match";
return;
}
const res = await Auth.auth_signup(username.value, password.value);
error.value = isErrorResponse(res)
? "Signup failed: " + res.message
: "Signup successful, please wait till an admin unlocks your account.";
const username = ref('');
const password = ref('');
const password2 = ref('');
const signup = loadingMsgWrapper(message, async () => {
if (username.value === '' || password.value === '') {
message.error('Email and/or Password missing');
return;
}
if (password.value !== password2.value) {
message.error("Passwords don't match");
return;
}
const res = await Auth.auth_signup(username.value, password.value);
if (isErrorResponse(res)) {
message.error(`Signup failed: ${res.message}`);
} else {
message.success(
'Signup successful, please wait till an admin unlocks your account.',
{
duration: 10000
}
);
}
});
function login() {
router.replace('login');
}
function onKey(event: KeyboardEvent) {
if (event.key == 'Enter') signup();
}
</script>
<template>
<div v-if="error !== ''" v-text="error"></div>
<input type="email" placeholder="Email" v-model="username" />
<input type="password" placeholder="Password" v-model="password" />
<input type="password" placeholder="Repeat password" v-model="password2" />
<button @click="signup()">Signup</button>
<router-link to="login">Login instead?</router-link>
<n-card>
<n-grid cols="2" x-gap="16" y-gap="16">
<n-gi span="2">
<n-input
type="text"
placeholder="Email"
v-model:value="username"
autofocus
:input-props="{ type: 'email' }"
@keyup="onKey"
/>
</n-gi>
<n-gi span="2">
<n-input
type="password"
placeholder="Password"
v-model:value="password"
@keyup="onKey"
/>
</n-gi>
<n-gi span="2">
<n-input
type="password"
placeholder="Repeat password"
v-model:value="password2"
@keyup="onKey"
/>
</n-gi>
<n-gi>
<n-button type="info" @click="signup">Signup</n-button>
</n-gi>
<n-gi>
<n-button ghost @click="login">Login instead?</n-button>
</n-gi>
</n-grid>
</n-card>
</template>
<style scoped></style>

View File

@@ -1,90 +1,138 @@
<script setup lang="ts">
import type { TokenInjectType } from "@/api";
import { ref, inject } from "vue";
import { Auth, check_token, isErrorResponse } from "@/api";
import type { TokenInjectType } from '@/api';
import { ref, inject } from 'vue';
import { Auth, check_token, isErrorResponse } from '@/api';
import {
useMessage,
NInput,
NGrid,
NGi,
NButton,
NImage,
NPopover,
NCard
} from 'naive-ui';
import { loadingMsgWrapper } from '@/utils';
const message = useMessage();
enum state {
SELECT,
MAIL,
TOTP,
SELECT,
MAIL,
TOTP
}
const currentState = ref<state>(state.SELECT);
const error = ref("");
const qrImage = ref("");
const secret = ref("");
const code = ref("");
const qrImage = ref('');
const secret = ref('');
const code = ref('');
const jwt = inject<TokenInjectType>("jwt") as TokenInjectType;
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;
}
}
const selectMail = loadingMsgWrapper(message, async () => {
const token = await check_token(jwt);
if (!token) return;
const res = await Auth.tfa_setup(true, token);
if (isErrorResponse(res))
message.error(`Failed to select 2fa type: ${res.message}`);
else 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;
}
}
const selectTotp = loadingMsgWrapper(message, async () => {
const token = await check_token(jwt);
if (!token) return;
const res = await Auth.tfa_setup(false, token);
if (isErrorResponse(res))
message.error(`Failed to select 2fa type: ${res.message}`);
else {
qrImage.value = res.qrCode;
secret.value = res.secret;
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();
const submit = loadingMsgWrapper(message, async () => {
const token = await check_token(jwt);
if (!token) return;
const res = await Auth.tfa_complete(
currentState.value === state.MAIL,
code.value,
token
);
if (isErrorResponse(res))
message.error(`Failed to submit code: ${res.message}`);
else jwt.logout();
});
function onKey(event: KeyboardEvent) {
if (event.key == 'Enter') submit();
}
</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>
<n-card>
<n-grid cols="2" x-gap="16" y-gap="16">
<template v-if="currentState === state.SELECT">
<n-gi span="2" style="text-align: center">
Select 2 Factor authentication type
</n-gi>
<n-gi>
<n-button @click="selectMail">Mail</n-button>
</n-gi>
<n-gi style="text-align: right">
<n-button @click="selectTotp"
>Google Authenticator</n-button
>
</n-gi>
</template>
<template v-else-if="currentState === state.MAIL">
<n-gi span="2" style="text-align: center">
Please enter the code you got by mail
</n-gi>
<n-gi>
<n-input
type="text"
placeholder="Code"
maxlength="6"
v-model:value="code"
@keyup="onKey"
/>
</n-gi>
<n-gi style="text-align: right">
<n-button @click="submit">Submit</n-button>
</n-gi>
</template>
<template v-else>
<n-gi span="2" style="text-align: center">
<n-image :src="qrImage" alt="QrCode" />
</n-gi>
<n-gi span="2" style="text-align: center">
<n-popover placement="bottom" trigger="click">
<template #trigger>
<n-button>Show manual input code</n-button>
</template>
{{ secret }}
</n-popover>
</n-gi>
<n-gi span="2" style="text-align: center">
Please enter the current code
</n-gi>
<n-gi>
<n-input
type="text"
placeholder="Code"
maxlength="6"
v-model:value="code"
@keyup="onKey"
/>
</n-gi>
<n-gi style="text-align: right">
<n-button @click="submit">Submit</n-button>
</n-gi>
</template>
</n-grid>
</n-card>
</template>
<style scoped></style>