mirror of
https://github.com/KohakuBlueleaf/KohakuHub.git
synced 2026-04-29 02:48:33 -05:00
kohakuboard frontned update to support remote mode
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
3
src/kohaku-board-ui/src/components.d.ts
vendored
3
src/kohaku-board-ui/src/components.d.ts
vendored
@@ -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']
|
||||
|
||||
@@ -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>
|
||||
|
||||
102
src/kohaku-board-ui/src/pages/login.vue
Normal file
102
src/kohaku-board-ui/src/pages/login.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
136
src/kohaku-board-ui/src/pages/register.vue
Normal file
136
src/kohaku-board-ui/src/pages/register.vue
Normal 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>
|
||||
115
src/kohaku-board-ui/src/stores/auth.js
Normal file
115
src/kohaku-board-ui/src/stores/auth.js
Normal 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)
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
10
src/kohaku-board-ui/src/typed-router.d.ts
vendored
10
src/kohaku-board-ui/src/typed-router.d.ts
vendored
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user