Rewrote Frontend
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 |
31
frontend/src/components/AsyncImage.vue
Normal file
31
frontend/src/components/AsyncImage.vue
Normal 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>
|
||||
90
frontend/src/components/DirViewer/CreateZipDialog.tsx
Normal file
90
frontend/src/components/DirViewer/CreateZipDialog.tsx
Normal 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;
|
||||
}
|
||||
74
frontend/src/components/DirViewer/DeleteModal.vue
Normal file
74
frontend/src/components/DirViewer/DeleteModal.vue
Normal 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>
|
||||
124
frontend/src/components/DirViewer/DirViewer.vue
Normal file
124
frontend/src/components/DirViewer/DirViewer.vue
Normal 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>
|
||||
379
frontend/src/components/DirViewer/DirViewerTable.vue
Normal file
379
frontend/src/components/DirViewer/DirViewerTable.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
45
frontend/src/components/FileViewer/AudioVideoDownload.tsx
Normal file
45
frontend/src/components/FileViewer/AudioVideoDownload.tsx
Normal 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 };
|
||||
}
|
||||
129
frontend/src/components/FileViewer/FileViewer.vue
Normal file
129
frontend/src/components/FileViewer/FileViewer.vue
Normal 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>
|
||||
@@ -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>
|
||||
13
frontend/src/components/NLink.vue
Normal file
13
frontend/src/components/NLink.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
203
frontend/src/components/UploadDialog/UploadField.vue
Normal file
203
frontend/src/components/UploadDialog/UploadField.vue
Normal 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 or drag here to upload 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>
|
||||
@@ -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>
|
||||
|
||||
73
frontend/src/components/UserChangePw.vue
Normal file
73
frontend/src/components/UserChangePw.vue
Normal 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>
|
||||
@@ -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[];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export class BaseRequest {}
|
||||
@@ -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 {}
|
||||
@@ -1,4 +0,0 @@
|
||||
export * from "./base";
|
||||
export * as Auth from "./auth";
|
||||
export * as FS from "./fs";
|
||||
export * as Admin from "./admin";
|
||||
@@ -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 {}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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";
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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
19
frontend/src/utils.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
<template>
|
||||
<div class="about">
|
||||
<h1>This is an about page</h1>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user