kohakuboard frontned update to support remote mode

This commit is contained in:
Kohaku-Blueleaf
2025-10-27 12:15:01 +08:00
parent a2c0603133
commit 8fef619f6f
9 changed files with 527 additions and 3 deletions

View File

@@ -1,12 +1,14 @@
<script setup>
import { ref, onMounted } from "vue";
import { initializeSliderSync } from "@/composables/useSliderSync";
import { useAuthStore } from "@/stores/auth";
import { getSystemInfo } from "@/utils/api";
import TheHeader from "@/components/layout/TheHeader.vue";
import TheFooter from "@/components/layout/TheFooter.vue";
const darkMode = ref(false);
const systemInfo = ref(null);
const authStore = useAuthStore();
onMounted(async () => {
const savedTheme = localStorage.getItem("theme");
@@ -21,6 +23,11 @@ onMounted(async () => {
// Fetch system info for mode detection
try {
systemInfo.value = await getSystemInfo();
// Initialize auth store if in remote mode
if (systemInfo.value?.mode === "remote") {
await authStore.init();
}
} catch (error) {
console.error("Failed to fetch system info:", error);
}

View File

@@ -19,6 +19,9 @@ declare module 'vue' {
ElCol: typeof import('element-plus/es')['ElCol']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDivider: typeof import('element-plus/es')['ElDivider']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']

View File

@@ -26,6 +26,47 @@
</div>
</div>
<div class="flex items-center gap-2">
<!-- Auth UI (only in remote mode) -->
<template v-if="isRemoteMode">
<template v-if="authStore.isAuthenticated">
<!-- User dropdown -->
<el-dropdown>
<div
class="flex items-center gap-2 cursor-pointer px-3 py-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<div
class="i-ep-user text-xl text-gray-700 dark:text-gray-300"
/>
<span class="text-sm text-gray-900 dark:text-gray-100">
{{ authStore.username }}
</span>
<div class="i-ep-arrow-down text-gray-500 dark:text-gray-400" />
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="handleLogout">
<div class="i-ep-switch-button inline-block mr-2" />
Logout
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<template v-else>
<el-button @click="$router.push('/login')" size="small" plain>
Login
</el-button>
<el-button
type="primary"
@click="$router.push('/register')"
size="small"
>
Sign Up
</el-button>
</template>
</template>
<!-- Theme toggle -->
<button
@click="toggleAnimations"
class="p-2 rounded-md transition-colors bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 text-lg"
@@ -51,8 +92,14 @@
<script setup>
import { useAnimationPreference } from "@/composables/useAnimationPreference";
import { useAuthStore } from "@/stores/auth";
import { getSystemInfo } from "@/utils/api";
import { ElMessage } from "element-plus";
const { animationsEnabled, toggleAnimations } = useAnimationPreference();
const authStore = useAuthStore();
const router = useRouter();
const systemInfo = ref(null);
const props = defineProps({
darkMode: {
@@ -63,7 +110,27 @@ const props = defineProps({
const emit = defineEmits(["toggle-dark-mode"]);
onMounted(async () => {
try {
systemInfo.value = await getSystemInfo();
} catch (err) {
console.error("Failed to load system info:", err);
}
});
const isRemoteMode = computed(() => systemInfo.value?.mode === "remote");
function toggleDarkMode() {
emit("toggle-dark-mode");
}
async function handleLogout() {
try {
await authStore.logout();
ElMessage.success("Logged out successfully");
router.push("/");
} catch (err) {
ElMessage.error("Logout failed");
}
}
</script>

View File

@@ -0,0 +1,102 @@
<template>
<div class="min-h-[calc(100vh-16rem)] flex items-center justify-center">
<div
class="w-full max-w-md bg-white dark:bg-gray-900 rounded-lg shadow-md p-8 border border-gray-200 dark:border-gray-800"
>
<h1
class="text-2xl font-bold mb-6 text-center text-gray-900 dark:text-gray-100"
>
Login to KohakuBoard
</h1>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
@submit.prevent="handleSubmit"
>
<el-form-item label="Username" prop="username">
<el-input
v-model="form.username"
placeholder="Enter your username"
size="large"
/>
</el-form-item>
<el-form-item label="Password" prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="Enter your password"
size="large"
show-password
/>
</el-form-item>
<el-button
type="primary"
size="large"
class="w-full"
:loading="loading"
@click="handleSubmit"
>
Login
</el-button>
</el-form>
<div class="mt-4 text-center text-sm text-gray-600 dark:text-gray-400">
Don't have an account?
<router-link
to="/register"
class="text-blue-500 dark:text-blue-400 hover:underline"
>
Sign up
</router-link>
</div>
</div>
</div>
</template>
<script setup>
import { useAuthStore } from "@/stores/auth";
import { ElMessage } from "element-plus";
const router = useRouter();
const authStore = useAuthStore();
const formRef = ref(null);
const loading = ref(false);
const form = reactive({
username: "",
password: "",
});
const rules = {
username: [
{ required: true, message: "Please enter username", trigger: "blur" },
],
password: [
{ required: true, message: "Please enter password", trigger: "blur" },
],
};
async function handleSubmit() {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (!valid) return;
loading.value = true;
try {
await authStore.login(form);
ElMessage.success("Login successful");
router.push("/");
} catch (err) {
ElMessage.error(err.response?.data?.detail || "Login failed");
} finally {
loading.value = false;
}
});
}
</script>

View File

@@ -1,14 +1,28 @@
<script setup>
import { ref, onMounted } from "vue";
import { ref, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import { fetchProjects } from "@/utils/api";
import { useAuthStore } from "@/stores/auth";
import { fetchProjects, getSystemInfo } from "@/utils/api";
const router = useRouter();
const authStore = useAuthStore();
const projects = ref([]);
const loading = ref(true);
const systemInfo = ref(null);
onMounted(async () => {
try {
// Get system mode
systemInfo.value = await getSystemInfo();
// Check if authentication required
if (systemInfo.value?.mode === "remote" && !authStore.isAuthenticated) {
// Don't fetch - show login prompt instead
loading.value = false;
return;
}
// Fetch projects
const result = await fetchProjects();
projects.value = result.projects;
} catch (error) {
@@ -18,6 +32,11 @@ onMounted(async () => {
}
});
const isRemoteMode = computed(() => systemInfo.value?.mode === "remote");
const needsAuth = computed(
() => isRemoteMode.value && !authStore.isAuthenticated,
);
function viewProject(projectName) {
router.push(`/projects/${projectName}`);
}
@@ -36,7 +55,35 @@ function formatDate(timestamp) {
</h1>
</div>
<div v-if="loading" class="text-center py-12">
<!-- Auth required prompt (remote mode only) -->
<div
v-if="needsAuth"
class="bg-white dark:bg-gray-900 rounded-lg shadow-md p-8 text-center border border-gray-200 dark:border-gray-800"
>
<div
class="i-ep-lock text-6xl text-gray-400 dark:text-gray-600 mb-4 inline-block"
/>
<h2 class="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-2">
Authentication Required
</h2>
<p class="text-gray-600 dark:text-gray-400 mb-6">
Please login to view your projects
</p>
<div class="flex gap-3 justify-center">
<el-button @click="$router.push('/login')" plain size="large">
Login
</el-button>
<el-button
type="primary"
@click="$router.push('/register')"
size="large"
>
Sign Up
</el-button>
</div>
</div>
<div v-else-if="loading" class="text-center py-12">
<div class="text-gray-500 dark:text-gray-400">Loading projects...</div>
</div>

View File

@@ -0,0 +1,136 @@
<template>
<div class="min-h-[calc(100vh-16rem)] flex items-center justify-center">
<div
class="w-full max-w-md bg-white dark:bg-gray-900 rounded-lg shadow-md p-8 border border-gray-200 dark:border-gray-800"
>
<h1
class="text-2xl font-bold mb-6 text-center text-gray-900 dark:text-gray-100"
>
Create Account
</h1>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
@submit.prevent="handleSubmit"
>
<el-form-item label="Username" prop="username">
<el-input
v-model="form.username"
placeholder="Choose a username"
size="large"
/>
</el-form-item>
<el-form-item label="Email" prop="email">
<el-input
v-model="form.email"
type="email"
placeholder="your@email.com"
size="large"
/>
</el-form-item>
<el-form-item label="Password" prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="Create a password"
size="large"
show-password
/>
</el-form-item>
<el-button
type="primary"
size="large"
class="w-full"
:loading="loading"
@click="handleSubmit"
>
Sign Up
</el-button>
</el-form>
<div class="mt-4 text-center text-sm text-gray-600 dark:text-gray-400">
Already have an account?
<router-link
to="/login"
class="text-blue-500 dark:text-blue-400 hover:underline"
>
Login
</router-link>
</div>
</div>
</div>
</template>
<script setup>
import { useAuthStore } from "@/stores/auth";
import { ElMessage } from "element-plus";
const router = useRouter();
const authStore = useAuthStore();
const formRef = ref(null);
const loading = ref(false);
const form = reactive({
username: "",
email: "",
password: "",
});
const rules = {
username: [
{ required: true, message: "Please enter username", trigger: "blur" },
{
min: 3,
message: "Username must be at least 3 characters",
trigger: "blur",
},
],
email: [
{ required: true, message: "Please enter email", trigger: "blur" },
{ type: "email", message: "Please enter valid email", trigger: "blur" },
],
password: [
{ required: true, message: "Please enter password", trigger: "blur" },
{
min: 6,
message: "Password must be at least 6 characters",
trigger: "blur",
},
],
};
async function handleSubmit() {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (!valid) return;
loading.value = true;
try {
const result = await authStore.register(form);
ElMessage.success(result.message || "Registration successful");
// Auto login if email verified
if (result.email_verified) {
await authStore.login({
username: form.username,
password: form.password,
});
router.push("/");
} else {
router.push("/login");
}
} catch (err) {
ElMessage.error(err.response?.data?.detail || "Registration failed");
} finally {
loading.value = false;
}
});
}
</script>

View File

@@ -0,0 +1,115 @@
// src/kohaku-board-ui/src/stores/auth.js
import { defineStore } from "pinia";
import { authAPI } from "@/utils/api";
export const useAuthStore = defineStore("auth", {
state: () => ({
user: null,
userOrganizations: [],
loading: false,
initialized: false,
}),
getters: {
isAuthenticated: (state) => !!state.user,
username: (state) => state.user?.username || null,
organizations: (state) => state.userOrganizations,
organizationNames: (state) =>
state.userOrganizations.map((org) => org.name),
},
actions: {
/**
* Login user
* @param {Object} credentials - {username, password}
*/
async login(credentials) {
this.loading = true;
try {
const { data } = await authAPI.login(credentials);
await this.fetchUserInfo();
return data;
} finally {
this.loading = false;
}
},
/**
* Register new user
* @param {Object} userData - {username, email, password}
*/
async register(userData) {
this.loading = true;
try {
const { data } = await authAPI.register(userData);
return data;
} finally {
this.loading = false;
}
},
/**
* Logout current user
*/
async logout() {
try {
await authAPI.logout();
} finally {
this.user = null;
this.userOrganizations = [];
}
},
/**
* Fetch current user info with organizations
*/
async fetchUserInfo() {
try {
const { data } = await authAPI.me();
this.user = data;
// Fetch user's organizations
if (data.username) {
const orgsData = await authAPI.listUserOrgs(data.username);
this.userOrganizations = orgsData.data.organizations || [];
}
return data;
} catch (err) {
this.user = null;
this.userOrganizations = [];
throw err;
}
},
/**
* Initialize auth state (restore session on app load)
*/
async init() {
if (this.initialized) return;
this.initialized = true;
try {
await this.fetchUserInfo();
} catch (err) {
// Session expired or invalid
this.user = null;
this.userOrganizations = [];
}
},
/**
* Check if user has write access to a namespace
* @param {string} namespace - Namespace to check
* @returns {boolean}
*/
canWriteToNamespace(namespace) {
if (!this.isAuthenticated) return false;
return (
this.username === namespace ||
this.organizationNames.includes(namespace)
);
},
},
});

View File

@@ -19,9 +19,11 @@ declare module 'vue-router/auto-routes' {
*/
export interface RouteNamedMap {
'/': RouteRecordInfo<'/', '/', Record<never, never>, Record<never, never>>,
'/login': RouteRecordInfo<'/login', '/login', Record<never, never>, Record<never, never>>,
'/projects/': RouteRecordInfo<'/projects/', '/projects', Record<never, never>, Record<never, never>>,
'/projects/[project]/': RouteRecordInfo<'/projects/[project]/', '/projects/:project', { project: ParamValue<true> }, { project: ParamValue<false> }>,
'/projects/[project]/[id]': RouteRecordInfo<'/projects/[project]/[id]', '/projects/:project/:id', { project: ParamValue<true>, id: ParamValue<true> }, { project: ParamValue<false>, id: ParamValue<false> }>,
'/register': RouteRecordInfo<'/register', '/register', Record<never, never>, Record<never, never>>,
}
/**
@@ -39,6 +41,10 @@ declare module 'vue-router/auto-routes' {
routes: '/'
views: never
}
'src/pages/login.vue': {
routes: '/login'
views: never
}
'src/pages/projects/index.vue': {
routes: '/projects/'
views: never
@@ -51,6 +57,10 @@ declare module 'vue-router/auto-routes' {
routes: '/projects/[project]/[id]'
views: never
}
'src/pages/register.vue': {
routes: '/register'
views: never
}
}
/**

View File

@@ -3,6 +3,7 @@ import axios from "axios";
const api = axios.create({
baseURL: "/api",
timeout: 30000,
withCredentials: true, // For session cookies
});
/**
@@ -337,4 +338,40 @@ export async function fetchTableData(boardId, name, params = {}) {
return response.data;
}
/**
* Auth API
*/
export const authAPI = {
register: (data) => api.post("/auth/register", data),
login: (data) => api.post("/auth/login", data),
logout: () => api.post("/auth/logout"),
me: () => api.get("/auth/me"),
createToken: (data) => api.post("/auth/tokens/create", data),
listTokens: () => api.get("/auth/tokens"),
revokeToken: (id) =>
axios.delete(`/api/auth/tokens/${id}`, { withCredentials: true }),
listUserOrgs: (username) =>
axios.get(`/org/users/${username}/orgs`, { withCredentials: true }),
};
/**
* Organization API
*/
export const orgAPI = {
create: (data) => axios.post("/org/create", data, { withCredentials: true }),
getInfo: (orgName) => axios.get(`/org/${orgName}`, { withCredentials: true }),
addMember: (orgName, data) =>
axios.post(`/org/${orgName}/members`, data, { withCredentials: true }),
removeMember: (orgName, username) =>
axios.delete(`/org/${orgName}/members/${username}`, {
withCredentials: true,
}),
updateMemberRole: (orgName, username, data) =>
axios.put(`/org/${orgName}/members/${username}`, data, {
withCredentials: true,
}),
listMembers: (orgName) =>
axios.get(`/org/${orgName}/members`, { withCredentials: true }),
};
export default api;