feat: support login
- migrate old API into typescript but not finished (only webLogin works now) - seperate the logger of backend due to the shitty behavior of Flask (change logging level)
This commit is contained in:
75
frontend/src/api/admin.ts
Normal file
75
frontend/src/api/admin.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { apiWrapper, boolApiWrapper } from './index'
|
||||
|
||||
// User interface
|
||||
interface User {
|
||||
username: string
|
||||
isAdmin: boolean
|
||||
// Add other user-related fields as needed
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all users (admin only)
|
||||
* @param token - Authentication token
|
||||
* @returns Array of users or null if operation failed
|
||||
*/
|
||||
export async function get(token: string): Promise<User[] | undefined> {
|
||||
return apiWrapper('/api/admin/get', { token });
|
||||
}
|
||||
|
||||
/**
|
||||
* Add new user (admin only)
|
||||
* @param token - Authentication token
|
||||
* @param username - Username
|
||||
* @returns Created user or null if operation failed
|
||||
*/
|
||||
export async function add(token: string, username: string): Promise<User | undefined> {
|
||||
return apiWrapper('/api/admin/add', { token, username });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user (admin only)
|
||||
* @param token - Authentication token
|
||||
* @param username - Username
|
||||
* @param params - Update parameters (partial)
|
||||
* @returns true if update successful, false otherwise
|
||||
*/
|
||||
export async function update(
|
||||
token: string,
|
||||
username: string,
|
||||
params: {
|
||||
password?: string
|
||||
isAdmin?: boolean
|
||||
}
|
||||
): Promise<boolean> {
|
||||
const data: any = {
|
||||
token,
|
||||
username
|
||||
};
|
||||
|
||||
let count = 0;
|
||||
if (typeof params.password !== 'undefined') {
|
||||
data.password = params.password;
|
||||
count++;
|
||||
}
|
||||
if (typeof params.isAdmin !== 'undefined') {
|
||||
data.isAdmin = params.isAdmin;
|
||||
count++;
|
||||
}
|
||||
|
||||
// If no update parameters provided, return true
|
||||
if (count === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return boolApiWrapper('/api/admin/update', data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete user (admin only)
|
||||
* @param token - Authentication token
|
||||
* @param username - Username
|
||||
* @returns true if deletion successful, false otherwise
|
||||
*/
|
||||
export async function deleteItem(token: string, username: string): Promise<boolean> {
|
||||
return boolApiWrapper('/api/admin/delete', { token, username });
|
||||
}
|
||||
141
frontend/src/api/calendar.ts
Normal file
141
frontend/src/api/calendar.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { apiWrapper, boolApiWrapper } from './index'
|
||||
|
||||
// Calendar event interface
|
||||
interface CalendarEvent {
|
||||
uuid: string
|
||||
belongTo: string
|
||||
title: string
|
||||
description: string
|
||||
eventDateTimeStart: string
|
||||
eventDateTimeEnd: string
|
||||
loopRules: string
|
||||
timezoneOffset: number
|
||||
lastChange: string
|
||||
}
|
||||
|
||||
// Description object interface
|
||||
interface DescriptionObject {
|
||||
description: string
|
||||
color: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize calendar description
|
||||
* @param description - Description text
|
||||
* @param color - Color value
|
||||
* @returns JSON string
|
||||
*/
|
||||
export function serializeDescription(description: string, color: string): string {
|
||||
const sobj: DescriptionObject = {
|
||||
description,
|
||||
color
|
||||
}
|
||||
return JSON.stringify(sobj)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize calendar description
|
||||
* @param str - JSON string
|
||||
* @returns Description object
|
||||
*/
|
||||
export function deserializeDescription(str: string): DescriptionObject {
|
||||
try {
|
||||
return JSON.parse(str) as DescriptionObject
|
||||
} catch (err) {
|
||||
return {
|
||||
description: "",
|
||||
color: "#000000" // DefaultColor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full calendar events within date range
|
||||
* @param token - Authentication token
|
||||
* @param startDateTime - Start datetime
|
||||
* @param endDateTime - End datetime
|
||||
* @returns Array of calendar events or null if operation failed
|
||||
*/
|
||||
export async function getFull(token: string, startDateTime: string, endDateTime: string): Promise<CalendarEvent[] | undefined> {
|
||||
return apiWrapper('/api/calendar/getFull', { token, startDateTime, endDateTime });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get calendar event detail by UUID
|
||||
* @param token - Authentication token
|
||||
* @param uuid - Event UUID
|
||||
* @returns Calendar event or null if operation failed
|
||||
*/
|
||||
export async function getDetail(token: string, uuid: string): Promise<CalendarEvent | undefined> {
|
||||
return apiWrapper('/api/calendar/getDetail', { token, uuid });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update calendar event
|
||||
* @param token - Authentication token
|
||||
* @param uuid - Event UUID
|
||||
* @param params - Update parameters (partial)
|
||||
* @returns Updated calendar event or null if operation failed
|
||||
*/
|
||||
export async function update(
|
||||
token: string,
|
||||
uuid: string,
|
||||
params: {
|
||||
belongTo?: string
|
||||
title?: string
|
||||
description?: string
|
||||
eventDateTimeStart?: string
|
||||
eventDateTimeEnd?: string
|
||||
loopRules?: string
|
||||
timezoneOffset?: number
|
||||
lastChange: string
|
||||
}
|
||||
): Promise<CalendarEvent | undefined> {
|
||||
const data: any = {
|
||||
token,
|
||||
uuid,
|
||||
lastChange: params.lastChange
|
||||
};
|
||||
|
||||
if (params.belongTo !== undefined) data.belongTo = params.belongTo;
|
||||
if (params.title !== undefined) data.title = params.title;
|
||||
if (params.description !== undefined) data.description = params.description;
|
||||
if (params.eventDateTimeStart !== undefined) data.eventDateTimeStart = params.eventDateTimeStart;
|
||||
if (params.eventDateTimeEnd !== undefined) data.eventDateTimeEnd = params.eventDateTimeEnd;
|
||||
if (params.loopRules !== undefined) data.loopRules = params.loopRules;
|
||||
if (params.timezoneOffset !== undefined) data.timezoneOffset = params.timezoneOffset;
|
||||
|
||||
return apiWrapper('/api/calendar/update', data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add new calendar event
|
||||
* @param token - Authentication token
|
||||
* @param params - Event parameters
|
||||
* @returns Created calendar event or null if operation failed
|
||||
*/
|
||||
export async function add(
|
||||
token: string,
|
||||
params: {
|
||||
belongTo: string
|
||||
title: string
|
||||
description: string
|
||||
eventDateTimeStart: string
|
||||
eventDateTimeEnd: string
|
||||
loopRules: string
|
||||
timezoneOffset: number
|
||||
}
|
||||
): Promise<CalendarEvent | undefined> {
|
||||
return apiWrapper('/api/calendar/add', { token, ...params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete calendar event
|
||||
* @param token - Authentication token
|
||||
* @param uuid - Event UUID
|
||||
* @param lastChange - Last change timestamp
|
||||
* @returns true if deletion successful, false otherwise
|
||||
*/
|
||||
export async function deleteEvent(token: string, uuid: string, lastChange: string): Promise<boolean> {
|
||||
return boolApiWrapper('/api/calendar/delete', { token, uuid, lastChange });
|
||||
}
|
||||
126
frontend/src/api/collection.ts
Normal file
126
frontend/src/api/collection.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { apiWrapper, boolApiWrapper } from './index'
|
||||
|
||||
type Uuid = string;
|
||||
|
||||
// Collection item interface
|
||||
interface CollectionItem {
|
||||
uuid: Uuid
|
||||
name: string
|
||||
lastChange: string
|
||||
}
|
||||
|
||||
// Sharing info interface
|
||||
interface SharingInfo {
|
||||
target: string
|
||||
// Add other sharing-related fields as needed
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all owned collections
|
||||
* @param token - Authentication token
|
||||
* @returns Array of collection items or null if operation failed
|
||||
*/
|
||||
export async function getFullOwn(token: string): Promise<CollectionItem[] | undefined> {
|
||||
return apiWrapper('/api/collection/getFullOwn', { token });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get owned collection detail by UUID
|
||||
* @param token - Authentication token
|
||||
* @param uuid - Collection UUID
|
||||
* @returns Collection item or null if operation failed
|
||||
*/
|
||||
export async function getDetailOwn(token: string, uuid: Uuid): Promise<CollectionItem | undefined> {
|
||||
return apiWrapper('/api/collection/getDetailOwn', { token, uuid });
|
||||
}
|
||||
|
||||
/**
|
||||
* Add new owned collection
|
||||
* @param token - Authentication token
|
||||
* @param name - Collection name
|
||||
* @returns Created collection item or null if operation failed
|
||||
*/
|
||||
export async function addOwn(token: string, name: string): Promise<Uuid | undefined> {
|
||||
return apiWrapper('/api/collection/addOwn', { token, name });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update owned collection
|
||||
* @param token - Authentication token
|
||||
* @param uuid - Collection UUID
|
||||
* @param name - New name
|
||||
* @param lastChange - Last change timestamp
|
||||
* @returns Updated collection item or null if operation failed
|
||||
*/
|
||||
export async function updateOwn(
|
||||
token: string,
|
||||
uuid: Uuid,
|
||||
name: string,
|
||||
lastChange: string
|
||||
): Promise<CollectionItem | undefined> {
|
||||
return apiWrapper('/api/collection/updateOwn', { token, uuid, name, lastChange });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete owned collection
|
||||
* @param token - Authentication token
|
||||
* @param uuid - Collection UUID
|
||||
* @param lastChange - Last change timestamp
|
||||
* @returns true if deletion successful, false otherwise
|
||||
*/
|
||||
export async function deleteOwn(token: string, uuid: Uuid, lastChange: string): Promise<boolean> {
|
||||
return boolApiWrapper('/api/collection/deleteOwn', { token, uuid, lastChange });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sharing information for a collection
|
||||
* @param token - Authentication token
|
||||
* @param uuid - Collection UUID
|
||||
* @returns Array of sharing info or null if operation failed
|
||||
*/
|
||||
export async function getSharing(token: string, uuid: Uuid): Promise<SharingInfo[] | undefined> {
|
||||
return apiWrapper('/api/collection/getSharing', { token, uuid });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete sharing for a collection
|
||||
* @param token - Authentication token
|
||||
* @param uuid - Collection UUID
|
||||
* @param target - Target user
|
||||
* @param lastChange - Last change timestamp
|
||||
* @returns Result data or null if operation failed
|
||||
*/
|
||||
export async function deleteSharing(
|
||||
token: string,
|
||||
uuid: Uuid,
|
||||
target: string,
|
||||
lastChange: string
|
||||
): Promise<any | undefined> {
|
||||
return apiWrapper('/api/collection/deleteSharing', { token, uuid, target, lastChange });
|
||||
}
|
||||
|
||||
/**
|
||||
* Add sharing for a collection
|
||||
* @param token - Authentication token
|
||||
* @param uuid - Collection UUID
|
||||
* @param target - Target user
|
||||
* @param lastChange - Last change timestamp
|
||||
* @returns Result data or null if operation failed
|
||||
*/
|
||||
export async function addSharing(
|
||||
token: string,
|
||||
uuid: Uuid,
|
||||
target: string,
|
||||
lastChange: string
|
||||
): Promise<any | undefined> {
|
||||
return apiWrapper('/api/collection/addSharing', { token, uuid, target, lastChange });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all shared collections
|
||||
* @param token - Authentication token
|
||||
* @returns Array of collection items or null if operation failed
|
||||
*/
|
||||
export async function getShared(token: string): Promise<CollectionItem[] | undefined> {
|
||||
return apiWrapper('/api/collection/getShared', { token });
|
||||
}
|
||||
43
frontend/src/api/common.ts
Normal file
43
frontend/src/api/common.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { apiWrapper, boolApiWrapper } from './index'
|
||||
|
||||
// /**
|
||||
// * Login with salt
|
||||
// * @param username - Username
|
||||
// * @param password - Password
|
||||
// * @returns Token if login successful, undefined otherwise
|
||||
// */
|
||||
// export async function login(username: string, password: string): Promise<string | undefined> {
|
||||
// const salt: string | undefined = await apiWrapper('/api/common/salt', { username });
|
||||
// if (typeof salt === 'undefined') return undefined;
|
||||
|
||||
// const computedPassword = computePasswordWithSalt(password, salt);
|
||||
// return apiWrapper('/api/common/login', { username, password: computedPassword });
|
||||
// }
|
||||
|
||||
/**
|
||||
* Web login
|
||||
* @param username - Username
|
||||
* @param password - Password
|
||||
* @returns Token if login successful, undefined otherwise
|
||||
*/
|
||||
export async function webLogin(username: string, password: string): Promise<string | undefined> {
|
||||
return apiWrapper('/api/common/webLogin', { username, password });
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout
|
||||
* @param token - Authentication token
|
||||
* @returns true if logout successful, false otherwise if logout failed
|
||||
*/
|
||||
export async function logout(token: string): Promise<boolean> {
|
||||
return boolApiWrapper('/api/common/logout', { token });
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate token
|
||||
* @param token - Authentication token
|
||||
* @returns true if token is valid, false otherwise
|
||||
*/
|
||||
export async function tokenValid(token: string): Promise<boolean> {
|
||||
return boolApiWrapper('/api/common/tokenValid', { token });
|
||||
}
|
||||
57
frontend/src/api/index.ts
Normal file
57
frontend/src/api/index.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
// Response interface
|
||||
interface ApiResponse<T = any> {
|
||||
success: boolean,
|
||||
error: string,
|
||||
data: T,
|
||||
}
|
||||
|
||||
export async function apiWrapper<T>(url: string, data: Record<string, any>): Promise<T | undefined> {
|
||||
try {
|
||||
// 自动编码为 key=value&key2=value2 格式
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
params.append(key, String(value));
|
||||
});
|
||||
// 发起请求
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
mode: "cors",
|
||||
cache: "no-cache",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
// 明确指定内容类型
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
redirect: "follow",
|
||||
referrerPolicy: "no-referrer",
|
||||
body: params.toString(),
|
||||
});
|
||||
|
||||
// 检查 HTTP 状态码 (fetch 只有在网络故障时才会 reject,HTTP 404/500 不会)
|
||||
if (!response.ok) {
|
||||
console.error(`HTTP failed: ${response.status}`);
|
||||
}
|
||||
|
||||
// 解析 JSON body
|
||||
// 注意:response.json() 返回的是一个 Promise,所以需要 await
|
||||
const payload = await response.json() as ApiResponse<T>;
|
||||
|
||||
// 检查API返回结果
|
||||
if (payload.success) {
|
||||
return payload.data;
|
||||
} else {
|
||||
console.error(`API failed: ${payload.error}`);
|
||||
return undefined;
|
||||
}
|
||||
} catch (error) {
|
||||
// 统一错误处理
|
||||
console.error(`Fetch failed: ${error}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function boolApiWrapper<U>(url: string, data: Record<string, any>): Promise<boolean> {
|
||||
const rv = await apiWrapper<null>(url, data);
|
||||
return rv !== undefined;
|
||||
}
|
||||
|
||||
45
frontend/src/api/profile.ts
Normal file
45
frontend/src/api/profile.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { apiWrapper, boolApiWrapper } from './index'
|
||||
|
||||
// Token info interface
|
||||
interface TokenInfo {
|
||||
token: string
|
||||
// Add other token-related fields as needed
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current user is admin
|
||||
* @param token - Authentication token
|
||||
* @returns true if user is admin, false otherwise
|
||||
*/
|
||||
export async function isAdmin(token: string): Promise<boolean> {
|
||||
return boolApiWrapper('/api/profile/isAdmin', { token });
|
||||
}
|
||||
|
||||
/**
|
||||
* Change user password
|
||||
* @param token - Authentication token
|
||||
* @param password - New password
|
||||
* @returns true if change successful, false otherwise
|
||||
*/
|
||||
export async function changePassword(token: string, password: string): Promise<boolean> {
|
||||
return boolApiWrapper('/api/profile/changePassword', { token, password });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user tokens
|
||||
* @param token - Authentication token
|
||||
* @returns Array of token info or undefined if operation failed
|
||||
*/
|
||||
export async function getToken(token: string): Promise<TokenInfo[] | undefined> {
|
||||
return apiWrapper('/api/profile/getToken', { token });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a token
|
||||
* @param token - Authentication token
|
||||
* @param deleteToken - Token to delete
|
||||
* @returns true if deletion successful, false otherwise
|
||||
*/
|
||||
export async function deleteToken(token: string, deleteToken: string): Promise<boolean> {
|
||||
return boolApiWrapper('/api/profile/deleteToken', { token, deleteToken });
|
||||
}
|
||||
56
frontend/src/api/todo.ts
Normal file
56
frontend/src/api/todo.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import axios from 'axios'
|
||||
import { apiWrapper, boolApiWrapper } from './index'
|
||||
|
||||
// Todo item interface
|
||||
interface TodoItem {
|
||||
uuid: string
|
||||
data: any
|
||||
lastChange: string
|
||||
// Add other todo-related fields as needed
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all todos
|
||||
* @param token - Authentication token
|
||||
* @returns Array of todo items or null if operation failed
|
||||
*/
|
||||
export async function getFull(token: string): Promise<TodoItem[] | undefined> {
|
||||
return apiWrapper('/api/todo/getFull', { token });
|
||||
}
|
||||
|
||||
/**
|
||||
* Add new todo
|
||||
* @param token - Authentication token
|
||||
* @returns Created todo item or null if operation failed
|
||||
*/
|
||||
export async function add(token: string): Promise<TodoItem | undefined> {
|
||||
return apiWrapper('/api/todo/add', { token });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update todo
|
||||
* @param token - Authentication token
|
||||
* @param uuid - Todo UUID
|
||||
* @param data - Todo data
|
||||
* @param lastChange - Last change timestamp
|
||||
* @returns Updated todo item or null if operation failed
|
||||
*/
|
||||
export async function update(
|
||||
token: string,
|
||||
uuid: string,
|
||||
data: any,
|
||||
lastChange: string
|
||||
): Promise<TodoItem | undefined> {
|
||||
return apiWrapper('/api/todo/update', { token, uuid, data, lastChange });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete todo
|
||||
* @param token - Authentication token
|
||||
* @param uuid - Todo UUID
|
||||
* @param lastChange - Last change timestamp
|
||||
* @returns true if deletion successful, false otherwise
|
||||
*/
|
||||
export async function deleteTodo(token: string, uuid: string, lastChange: string): Promise<boolean> {
|
||||
return boolApiWrapper('/api/todo/delete', { token, uuid, lastChange });
|
||||
}
|
||||
@@ -48,4 +48,8 @@ router.beforeEach((to, from) => {
|
||||
}
|
||||
})
|
||||
|
||||
export const goToHome = () => {
|
||||
router.push({ name: 'Home' })
|
||||
}
|
||||
|
||||
export default router
|
||||
|
||||
@@ -1,11 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import MessageBox from '@/components/MessageBox.vue';
|
||||
import { ref } from 'vue';
|
||||
import MessageBox from '@/components/MessageBox.vue';
|
||||
import { useTokenStore } from '@/stores/token';
|
||||
import { webLogin as apiCommonWebLogin } from '@/api/common';
|
||||
import { goToHome } from '@/router';
|
||||
|
||||
const isLoggingIn = ref<boolean>(false);
|
||||
const username = ref<string>("");
|
||||
const password = ref<string>("");
|
||||
|
||||
const messagebox = ref<InstanceType<typeof MessageBox> | null>(null);
|
||||
|
||||
const login = () => {
|
||||
messagebox.value?.show("Fail to login. Please check your username or password.")
|
||||
const login = async () => {
|
||||
// disable UI first
|
||||
isLoggingIn.value = true;
|
||||
|
||||
// // try get salt
|
||||
// if (ccn_api_common_salt(username)) {
|
||||
// // continue login
|
||||
// if (ccn_api_common_login(username, password)) {
|
||||
// // ok, logged
|
||||
// // jump into home page again
|
||||
// window.location.href = '/web/home';
|
||||
// } else ccn_messagebox_Show($.i18n.prop("ccn-i18n-js-fail-login"));
|
||||
// } else ccn_messagebox_Show($.i18n.prop("ccn-i18n-js-fail-login"));
|
||||
|
||||
const token = await apiCommonWebLogin(username.value, password.value);
|
||||
if (typeof token !== 'undefined') {
|
||||
// OK. We have logged in.
|
||||
// Update token storage
|
||||
const tokenStore = useTokenStore();
|
||||
tokenStore.login(token);
|
||||
// Go to home page.
|
||||
goToHome();
|
||||
} else {
|
||||
// Show login error.
|
||||
messagebox.value?.show("Fail to login. Please check your username or password.");
|
||||
}
|
||||
|
||||
// Enable all UI
|
||||
isLoggingIn.value = false;
|
||||
}
|
||||
|
||||
</script>
|
||||
@@ -16,7 +50,7 @@ const login = () => {
|
||||
<div class="field">
|
||||
<label class="label">User Name</label>
|
||||
<div class="control has-icons-left has-icons-right">
|
||||
<input id="ccn-login-form-username" class="input" type="text">
|
||||
<input v-model="username" :disabled="isLoggingIn" class="input" type="text">
|
||||
<span class="icon is-small is-left">
|
||||
<font-awesome-icon icon="fas fa-user"></font-awesome-icon>
|
||||
</span>
|
||||
@@ -25,7 +59,7 @@ const login = () => {
|
||||
<div class="field">
|
||||
<label class="label">Password</label>
|
||||
<p class="control has-icons-left">
|
||||
<input id="ccn-login-form-password" class="input" type="password">
|
||||
<input v-model="password" :disabled="isLoggingIn" class="input" type="password">
|
||||
<span class="icon is-small is-left">
|
||||
<font-awesome-icon icon="fas fa-lock"></font-awesome-icon>
|
||||
</span>
|
||||
@@ -33,7 +67,7 @@ const login = () => {
|
||||
</div>
|
||||
|
||||
<div class="control">
|
||||
<button class="button is-primary" @click="login">Login</button>
|
||||
<button class="button is-primary" :disabled="isLoggingIn" @click="login">Login</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user