feat: add user management system with roles and permissions
- Introduce SQLite persistence via GORM (stored at <data>/.ushare.db) - Add Role model with two built-in roles: admin (all perms) and user (upload only) - Add three permissions: user_manage, upload, token_manage (reserved) - Rewrite UserManager: DB-backed login with in-memory session tokens - Auto-seed default roles and admin user on first startup - Add AuthPermission middleware for fine-grained permission checks - Add /api/uauth/me endpoint for current session info - Add /api/admin/* CRUD routes for user and role management - Add admin console page (/admin) with user table and role permissions view - Show admin console link in share page for users with user_manage permission 🤖 Generated with [Qoder][https://qoder.com]
This commit is contained in:
@@ -6,28 +6,17 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build ushare:
|
build ushare:
|
||||||
runs-on: tencent-sg
|
runs-on: debian
|
||||||
steps:
|
steps:
|
||||||
- name: prepare enviroment
|
- name: prepare enviroment
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: prints date
|
- name: prints info
|
||||||
run: date '+%Y-%m-%dT%H:%M:%S'
|
|
||||||
|
|
||||||
- name: print operator
|
|
||||||
run: whoami
|
|
||||||
|
|
||||||
- name: print tag name
|
|
||||||
run: echo "Tag name = ${{ gitea.ref_name }}"
|
|
||||||
|
|
||||||
- name: build prepare config
|
|
||||||
run: |
|
run: |
|
||||||
cat << EOF > .docker.config.json
|
date '+%Y-%m-%dT%H:%M:%S'
|
||||||
${{ secrets.DOCKER_CONFIG }}
|
whoami
|
||||||
EOF
|
echo "Tag name = ${{ gitea.ref_name }}"
|
||||||
|
pwd & ls -alsh .
|
||||||
- name: print work dir and files
|
|
||||||
run: pwd & ls -alsh .
|
|
||||||
|
|
||||||
- name: build image by docker build
|
- name: build image by docker build
|
||||||
run: docker build -t gitea.loveuer.com/loveuer/build/ushare:${{ gitea.ref_name }} .
|
run: docker build -t gitea.loveuer.com/loveuer/build/ushare:${{ gitea.ref_name }} .
|
||||||
@@ -38,21 +27,9 @@ jobs:
|
|||||||
- name: push image to repository
|
- name: push image to repository
|
||||||
run: docker push gitea.loveuer.com/loveuer/build/ushare:${{ gitea.ref_name }}
|
run: docker push gitea.loveuer.com/loveuer/build/ushare:${{ gitea.ref_name }}
|
||||||
|
|
||||||
# - name: build by kaniko in docker
|
|
||||||
# run: |
|
|
||||||
# docker run --rm -v $(pwd):/workspace \
|
|
||||||
# -v $(pwd)/.docker.config.json:/kaniko/.docker/config.json:ro \
|
|
||||||
# alpine:latest \
|
|
||||||
# ls -alsh /workspace
|
|
||||||
# gcr.io/kaniko-project/executor:latest \
|
|
||||||
# --dockerfile=/workspace/Dockerfile \
|
|
||||||
# --context=/workspace \
|
|
||||||
# --destination=gitea.loveuer.com/loveuer/build/u-api:${{ gitea.ref_name }} \
|
|
||||||
# --single-snapshot
|
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
if: always()
|
if: always()
|
||||||
runs-on: tencent-sg
|
runs-on: debian
|
||||||
steps:
|
steps:
|
||||||
- name: clean docker config
|
- name: clean docker config
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
89
frontend/src/api/admin.ts
Normal file
89
frontend/src/api/admin.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
export interface Role {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
permissions: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminUser {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
role_id: number;
|
||||||
|
role: Role;
|
||||||
|
active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateUserReq {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
role_id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateUserReq {
|
||||||
|
role_id?: number;
|
||||||
|
active?: boolean;
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonHeaders: HeadersInit = {'Content-Type': 'application/json'};
|
||||||
|
|
||||||
|
export const adminApi = {
|
||||||
|
listUsers: async (): Promise<AdminUser[]> => {
|
||||||
|
const res = await fetch('/api/admin/users', {headers: jsonHeaders});
|
||||||
|
if (!res.ok) {
|
||||||
|
const json = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(json.msg || '查询失败');
|
||||||
|
}
|
||||||
|
return (await res.json()).data;
|
||||||
|
},
|
||||||
|
|
||||||
|
createUser: async (req: CreateUserReq): Promise<AdminUser> => {
|
||||||
|
const res = await fetch('/api/admin/users', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: jsonHeaders,
|
||||||
|
body: JSON.stringify(req),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const json = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(json.msg || '创建失败');
|
||||||
|
}
|
||||||
|
return (await res.json()).data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateUser: async (id: number, req: UpdateUserReq): Promise<AdminUser> => {
|
||||||
|
const res = await fetch(`/api/admin/users/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: jsonHeaders,
|
||||||
|
body: JSON.stringify(req),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const json = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(json.msg || '更新失败');
|
||||||
|
}
|
||||||
|
return (await res.json()).data;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteUser: async (id: number): Promise<void> => {
|
||||||
|
const res = await fetch(`/api/admin/users/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: jsonHeaders,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const json = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(json.msg || '删除失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
listRoles: async (): Promise<Role[]> => {
|
||||||
|
const res = await fetch('/api/admin/roles', {headers: jsonHeaders});
|
||||||
|
if (!res.ok) {
|
||||||
|
const json = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(json.msg || '查询失败');
|
||||||
|
}
|
||||||
|
return (await res.json()).data;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -6,12 +6,14 @@ import {Login} from "./page/login.tsx";
|
|||||||
import {FileSharing} from "./page/share/share.tsx";
|
import {FileSharing} from "./page/share/share.tsx";
|
||||||
import {LocalSharing} from "./page/local/local.tsx";
|
import {LocalSharing} from "./page/local/local.tsx";
|
||||||
import {TestPage} from "./page/test/test.tsx";
|
import {TestPage} from "./page/test/test.tsx";
|
||||||
|
import {AdminPage} from "./page/admin/admin.tsx";
|
||||||
|
|
||||||
const container = document.getElementById('root')
|
const container = document.getElementById('root')
|
||||||
const root = createRoot(container!)
|
const root = createRoot(container!)
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
{path: "/login", element: <Login />},
|
{path: "/login", element: <Login />},
|
||||||
{path: "/share", element: <FileSharing />},
|
{path: "/share", element: <FileSharing />},
|
||||||
|
{path: "/admin", element: <AdminPage />},
|
||||||
{path: "/test", element: <TestPage />},
|
{path: "/test", element: <TestPage />},
|
||||||
{path: "*", element: <LocalSharing />},
|
{path: "*", element: <LocalSharing />},
|
||||||
])
|
])
|
||||||
|
|||||||
533
frontend/src/page/admin/admin.tsx
Normal file
533
frontend/src/page/admin/admin.tsx
Normal file
@@ -0,0 +1,533 @@
|
|||||||
|
import React, {useEffect, useState} from 'react';
|
||||||
|
import {createUseStyles} from 'react-jss';
|
||||||
|
import {adminApi, AdminUser, Role, UpdateUserReq} from '../../api/admin.ts';
|
||||||
|
import {message} from '../../hook/message/u-message.tsx';
|
||||||
|
import {UButton} from '../../component/button/u-button.tsx';
|
||||||
|
|
||||||
|
const PERM_LABELS: Record<string, string> = {
|
||||||
|
user_manage: '用户管理',
|
||||||
|
upload: '上传文件',
|
||||||
|
token_manage: 'Token管理',
|
||||||
|
};
|
||||||
|
|
||||||
|
const useStyle = createUseStyles({
|
||||||
|
container: {
|
||||||
|
minHeight: '100vh',
|
||||||
|
backgroundColor: '#e3f2fd',
|
||||||
|
padding: '24px',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
fontFamily: "'Segoe UI', Arial, sans-serif",
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '16px',
|
||||||
|
marginBottom: '24px',
|
||||||
|
},
|
||||||
|
backBtn: {
|
||||||
|
background: 'transparent',
|
||||||
|
border: '2px solid #2c9678',
|
||||||
|
color: '#2c9678',
|
||||||
|
borderRadius: '6px',
|
||||||
|
padding: '6px 14px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
transition: 'background-color 0.2s',
|
||||||
|
'&:hover': {backgroundColor: 'rgba(44,150,120,0.1)'},
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
color: '#2c9678',
|
||||||
|
margin: 0,
|
||||||
|
fontSize: '22px',
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
backgroundColor: '#C8E6C9',
|
||||||
|
boxShadow: 'inset 0 0 15px rgba(56, 142, 60, 0.15)',
|
||||||
|
borderRadius: '15px',
|
||||||
|
padding: '24px',
|
||||||
|
marginBottom: '24px',
|
||||||
|
},
|
||||||
|
cardHeader: {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: '16px',
|
||||||
|
},
|
||||||
|
cardTitle: {
|
||||||
|
color: '#2c9678',
|
||||||
|
margin: 0,
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
width: '100%',
|
||||||
|
borderCollapse: 'collapse',
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.7)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
th: {
|
||||||
|
backgroundColor: 'rgba(44,150,120,0.15)',
|
||||||
|
color: '#2c9678',
|
||||||
|
padding: '10px 14px',
|
||||||
|
textAlign: 'left',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
td: {
|
||||||
|
padding: '10px 14px',
|
||||||
|
borderBottom: '1px solid rgba(44,150,120,0.1)',
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
badgeActive: {
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: '2px 10px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
backgroundColor: '#c8e6c9',
|
||||||
|
color: '#2e7d32',
|
||||||
|
},
|
||||||
|
badgeInactive: {
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: '2px 10px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
backgroundColor: '#ffcdd2',
|
||||||
|
color: '#c62828',
|
||||||
|
},
|
||||||
|
actionBtn: {
|
||||||
|
background: 'transparent',
|
||||||
|
border: '1px solid #2c9678',
|
||||||
|
color: '#2c9678',
|
||||||
|
borderRadius: '4px',
|
||||||
|
padding: '4px 10px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '12px',
|
||||||
|
marginRight: '6px',
|
||||||
|
transition: 'background-color 0.2s',
|
||||||
|
'&:hover': {backgroundColor: 'rgba(44,150,120,0.1)'},
|
||||||
|
},
|
||||||
|
deleteBtn: {
|
||||||
|
background: 'transparent',
|
||||||
|
border: '1px solid #c62828',
|
||||||
|
color: '#c62828',
|
||||||
|
borderRadius: '4px',
|
||||||
|
padding: '4px 10px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '12px',
|
||||||
|
transition: 'background-color 0.2s',
|
||||||
|
'&:hover': {backgroundColor: 'rgba(198,40,40,0.08)'},
|
||||||
|
},
|
||||||
|
permTag: {
|
||||||
|
display: 'inline-block',
|
||||||
|
backgroundColor: 'rgba(44,150,120,0.15)',
|
||||||
|
color: '#2c9678',
|
||||||
|
borderRadius: '10px',
|
||||||
|
padding: '2px 8px',
|
||||||
|
fontSize: '12px',
|
||||||
|
marginRight: '4px',
|
||||||
|
marginBottom: '2px',
|
||||||
|
},
|
||||||
|
overlay: {
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0, left: 0, right: 0, bottom: 0,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.4)',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
zIndex: 1000,
|
||||||
|
},
|
||||||
|
dialog: {
|
||||||
|
backgroundColor: '#C8E6C9',
|
||||||
|
boxShadow: '0 8px 32px rgba(44,150,120,0.2)',
|
||||||
|
borderRadius: '15px',
|
||||||
|
padding: '28px',
|
||||||
|
width: '400px',
|
||||||
|
maxWidth: '90vw',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
},
|
||||||
|
dialogTitle: {
|
||||||
|
color: '#2c9678',
|
||||||
|
margin: '0 0 20px',
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
formRow: {
|
||||||
|
marginBottom: '14px',
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
display: 'block',
|
||||||
|
color: '#2c9678',
|
||||||
|
fontSize: '13px',
|
||||||
|
marginBottom: '5px',
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
width: '100%',
|
||||||
|
padding: '9px 11px',
|
||||||
|
border: '2px solid #ddd',
|
||||||
|
borderRadius: '5px',
|
||||||
|
fontSize: '14px',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
background: 'rgba(255,255,255,0.8)',
|
||||||
|
transition: 'border-color 0.2s',
|
||||||
|
'&:focus': {outline: 'none', borderColor: '#2c9678'},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
width: '100%',
|
||||||
|
padding: '9px 11px',
|
||||||
|
border: '2px solid #ddd',
|
||||||
|
borderRadius: '5px',
|
||||||
|
fontSize: '14px',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
background: 'rgba(255,255,255,0.8)',
|
||||||
|
transition: 'border-color 0.2s',
|
||||||
|
'&:focus': {outline: 'none', borderColor: '#2c9678'},
|
||||||
|
},
|
||||||
|
checkboxRow: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
},
|
||||||
|
dialogActions: {
|
||||||
|
display: 'flex',
|
||||||
|
gap: '10px',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
marginTop: '20px',
|
||||||
|
},
|
||||||
|
cancelBtn: {
|
||||||
|
background: 'transparent',
|
||||||
|
border: '2px solid #aaa',
|
||||||
|
color: '#666',
|
||||||
|
borderRadius: '5px',
|
||||||
|
padding: '8px 18px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
transition: 'border-color 0.2s',
|
||||||
|
'&:hover': {borderColor: '#888'},
|
||||||
|
},
|
||||||
|
dangerBtn: {
|
||||||
|
backgroundColor: '#c62828',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '5px',
|
||||||
|
padding: '8px 18px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
transition: 'background-color 0.2s',
|
||||||
|
'&:hover': {backgroundColor: '#b71c1c'},
|
||||||
|
},
|
||||||
|
emptyTip: {
|
||||||
|
textAlign: 'center',
|
||||||
|
color: '#999',
|
||||||
|
padding: '24px',
|
||||||
|
fontSize: '14px',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
type DialogMode = 'create' | 'edit';
|
||||||
|
|
||||||
|
interface DialogState {
|
||||||
|
open: boolean;
|
||||||
|
mode: DialogMode;
|
||||||
|
user?: AdminUser;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
roleId: number;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyDialog = (defaultRoleId = 0): DialogState => ({
|
||||||
|
open: false,
|
||||||
|
mode: 'create',
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
roleId: defaultRoleId,
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AdminPage: React.FC = () => {
|
||||||
|
const classes = useStyle();
|
||||||
|
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||||
|
const [roles, setRoles] = useState<Role[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [dialog, setDialog] = useState<DialogState>(emptyDialog());
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<AdminUser | null>(null);
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
const [u, r] = await Promise.all([adminApi.listUsers(), adminApi.listRoles()]);
|
||||||
|
setUsers(u ?? []);
|
||||||
|
setRoles(r ?? []);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
message.error(e instanceof Error ? e.message : '加载失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/uauth/me').then(res => {
|
||||||
|
if (res.status === 401) {
|
||||||
|
window.location.href = '/login?next=/admin';
|
||||||
|
} else if (res.status === 403) {
|
||||||
|
message.error('权限不足');
|
||||||
|
} else {
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
window.location.href = '/login?next=/admin';
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
const defaultRoleId = roles.find(r => r.name === 'user')?.id ?? roles[0]?.id ?? 0;
|
||||||
|
setDialog({...emptyDialog(defaultRoleId), open: true, mode: 'create'});
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (user: AdminUser) => {
|
||||||
|
setDialog({
|
||||||
|
open: true,
|
||||||
|
mode: 'edit',
|
||||||
|
user,
|
||||||
|
username: user.username,
|
||||||
|
password: '',
|
||||||
|
roleId: user.role_id,
|
||||||
|
active: user.active,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDialog = () => setDialog(emptyDialog());
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (dialog.mode === 'create') {
|
||||||
|
if (!dialog.username.trim()) return message.warning('请输入用户名');
|
||||||
|
if (!dialog.password) return message.warning('请输入密码');
|
||||||
|
if (!dialog.roleId) return message.warning('请选择角色');
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (dialog.mode === 'create') {
|
||||||
|
await adminApi.createUser({
|
||||||
|
username: dialog.username.trim(),
|
||||||
|
password: dialog.password,
|
||||||
|
role_id: dialog.roleId,
|
||||||
|
});
|
||||||
|
message.success('创建成功');
|
||||||
|
} else if (dialog.user) {
|
||||||
|
const req: UpdateUserReq = {};
|
||||||
|
if (dialog.roleId !== dialog.user.role_id) req.role_id = dialog.roleId;
|
||||||
|
if (dialog.active !== dialog.user.active) req.active = dialog.active;
|
||||||
|
if (dialog.password) req.password = dialog.password;
|
||||||
|
await adminApi.updateUser(dialog.user.id, req);
|
||||||
|
message.success('更新成功');
|
||||||
|
}
|
||||||
|
closeDialog();
|
||||||
|
await loadData();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
message.error(e instanceof Error ? e.message : '操作失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
try {
|
||||||
|
await adminApi.deleteUser(deleteTarget.id);
|
||||||
|
message.success('删除成功');
|
||||||
|
setDeleteTarget(null);
|
||||||
|
await loadData();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
message.error(e instanceof Error ? e.message : '删除失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.container}>
|
||||||
|
<div className={classes.header}>
|
||||||
|
<button className={classes.backBtn} onClick={() => window.location.href = '/share'}>
|
||||||
|
← 返回
|
||||||
|
</button>
|
||||||
|
<h2 className={classes.title}>管理控制台</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Users */}
|
||||||
|
<div className={classes.card}>
|
||||||
|
<div className={classes.cardHeader}>
|
||||||
|
<h3 className={classes.cardTitle}>用户管理</h3>
|
||||||
|
<UButton onClick={openCreate}>添加用户</UButton>
|
||||||
|
</div>
|
||||||
|
{loading ? (
|
||||||
|
<div className={classes.emptyTip}>加载中...</div>
|
||||||
|
) : (
|
||||||
|
<table className={classes.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className={classes.th}>ID</th>
|
||||||
|
<th className={classes.th}>用户名</th>
|
||||||
|
<th className={classes.th}>角色</th>
|
||||||
|
<th className={classes.th}>状态</th>
|
||||||
|
<th className={classes.th}>创建时间</th>
|
||||||
|
<th className={classes.th}>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td className={classes.td} colSpan={6}>
|
||||||
|
<div className={classes.emptyTip}>暂无用户</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : users.map(u => (
|
||||||
|
<tr key={u.id}>
|
||||||
|
<td className={classes.td}>{u.id}</td>
|
||||||
|
<td className={classes.td}>{u.username}</td>
|
||||||
|
<td className={classes.td}>{u.role?.label ?? '-'}</td>
|
||||||
|
<td className={classes.td}>
|
||||||
|
<span className={u.active ? classes.badgeActive : classes.badgeInactive}>
|
||||||
|
{u.active ? '启用' : '禁用'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className={classes.td}>
|
||||||
|
{new Date(u.created_at).toLocaleDateString('zh-CN')}
|
||||||
|
</td>
|
||||||
|
<td className={classes.td}>
|
||||||
|
<button className={classes.actionBtn} onClick={() => openEdit(u)}>编辑</button>
|
||||||
|
<button className={classes.deleteBtn} onClick={() => setDeleteTarget(u)}>删除</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Roles */}
|
||||||
|
<div className={classes.card}>
|
||||||
|
<div className={classes.cardHeader}>
|
||||||
|
<h3 className={classes.cardTitle}>角色权限</h3>
|
||||||
|
</div>
|
||||||
|
<table className={classes.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className={classes.th}>角色</th>
|
||||||
|
<th className={classes.th}>权限</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{roles.map(r => (
|
||||||
|
<tr key={r.id}>
|
||||||
|
<td className={classes.td}>{r.label}</td>
|
||||||
|
<td className={classes.td}>
|
||||||
|
{r.permissions.split(',')
|
||||||
|
.map(p => p.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(p => (
|
||||||
|
<span key={p} className={classes.permTag}>
|
||||||
|
{PERM_LABELS[p] ?? p}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create / Edit Dialog */}
|
||||||
|
{dialog.open && (
|
||||||
|
<div className={classes.overlay} onClick={e => e.target === e.currentTarget && closeDialog()}>
|
||||||
|
<div className={classes.dialog}>
|
||||||
|
<h3 className={classes.dialogTitle}>
|
||||||
|
{dialog.mode === 'create' ? '添加用户' : `编辑用户: ${dialog.user?.username}`}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{dialog.mode === 'create' && (
|
||||||
|
<div className={classes.formRow}>
|
||||||
|
<label className={classes.label}>用户名</label>
|
||||||
|
<input
|
||||||
|
className={classes.input}
|
||||||
|
placeholder="请输入用户名"
|
||||||
|
value={dialog.username}
|
||||||
|
onChange={e => setDialog(d => ({...d, username: e.target.value}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={classes.formRow}>
|
||||||
|
<label className={classes.label}>
|
||||||
|
{dialog.mode === 'create' ? '密码' : '新密码(留空则不修改)'}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className={classes.input}
|
||||||
|
type="password"
|
||||||
|
placeholder={dialog.mode === 'create' ? '请输入密码' : '留空不修改'}
|
||||||
|
value={dialog.password}
|
||||||
|
onChange={e => setDialog(d => ({...d, password: e.target.value}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={classes.formRow}>
|
||||||
|
<label className={classes.label}>角色</label>
|
||||||
|
<select
|
||||||
|
className={classes.select}
|
||||||
|
value={dialog.roleId}
|
||||||
|
onChange={e => setDialog(d => ({...d, roleId: Number(e.target.value)}))}
|
||||||
|
>
|
||||||
|
{roles.map(r => (
|
||||||
|
<option key={r.id} value={r.id}>{r.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dialog.mode === 'edit' && (
|
||||||
|
<div className={classes.formRow}>
|
||||||
|
<label className={classes.label}>状态</label>
|
||||||
|
<div className={classes.checkboxRow}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="active-check"
|
||||||
|
checked={dialog.active}
|
||||||
|
onChange={e => setDialog(d => ({...d, active: e.target.checked}))}
|
||||||
|
/>
|
||||||
|
<label htmlFor="active-check" style={{color: '#555', fontSize: '14px', cursor: 'pointer'}}>
|
||||||
|
{dialog.active ? '启用' : '禁用'}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={classes.dialogActions}>
|
||||||
|
<button className={classes.cancelBtn} onClick={closeDialog}>取消</button>
|
||||||
|
<UButton onClick={handleSave} loading={saving}>
|
||||||
|
{dialog.mode === 'create' ? '创建' : '保存'}
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete Confirm Dialog */}
|
||||||
|
{deleteTarget && (
|
||||||
|
<div className={classes.overlay} onClick={e => e.target === e.currentTarget && setDeleteTarget(null)}>
|
||||||
|
<div className={classes.dialog}>
|
||||||
|
<h3 className={classes.dialogTitle}>确认删除</h3>
|
||||||
|
<p style={{color: '#555', marginTop: 0, marginBottom: '20px', fontSize: '14px'}}>
|
||||||
|
确定要删除用户 <strong>{deleteTarget.username}</strong> 吗?此操作不可撤销。
|
||||||
|
</p>
|
||||||
|
<div className={classes.dialogActions}>
|
||||||
|
<button className={classes.cancelBtn} onClick={() => setDeleteTarget(null)}>取消</button>
|
||||||
|
<button className={classes.dangerBtn} onClick={handleDelete}>确认删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import {createUseStyles} from "react-jss";
|
import {createUseStyles} from "react-jss";
|
||||||
import {UButton} from "../../../component/button/u-button.tsx";
|
import {UButton} from "../../../component/button/u-button.tsx";
|
||||||
import React, {useState} from "react";
|
import React, {useEffect, useState} from "react";
|
||||||
import {useStore} from "../../../store/share.ts";
|
import {useStore} from "../../../store/share.ts";
|
||||||
import {message} from "../../../hook/message/u-message.tsx";
|
import {message} from "../../../hook/message/u-message.tsx";
|
||||||
import {useFileUpload} from "../../../api/upload.ts";
|
import {useFileUpload} from "../../../api/upload.ts";
|
||||||
@@ -59,7 +59,17 @@ const useUploadStyle = createUseStyles({
|
|||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
'&:hover': {}
|
'&:hover': {}
|
||||||
}
|
},
|
||||||
|
adminLink: {
|
||||||
|
display: 'block',
|
||||||
|
textAlign: 'center',
|
||||||
|
marginTop: '16px',
|
||||||
|
color: '#2c9678',
|
||||||
|
fontSize: '12px',
|
||||||
|
textDecoration: 'none',
|
||||||
|
opacity: 0.8,
|
||||||
|
'&:hover': {opacity: 1, textDecoration: 'underline'},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const useShowStyle = createUseStyles({
|
const useShowStyle = createUseStyles({
|
||||||
@@ -179,6 +189,17 @@ const PanelLeftUpload: React.FC<{ set_code: (code:string) => void }> = ({set_cod
|
|||||||
const style = useUploadStyle()
|
const style = useUploadStyle()
|
||||||
const {file, setFile} = useStore()
|
const {file, setFile} = useStore()
|
||||||
const {uploadFile, progress, loading} = useFileUpload();
|
const {uploadFile, progress, loading} = useFileUpload();
|
||||||
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/uauth/me').then(async res => {
|
||||||
|
if (res.ok) {
|
||||||
|
const json = await res.json();
|
||||||
|
const perms: string[] = json.data?.permissions ?? [];
|
||||||
|
setIsAdmin(perms.includes('user_manage'));
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
function onFileSelect() {
|
function onFileSelect() {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -225,6 +246,9 @@ const PanelLeftUpload: React.FC<{ set_code: (code:string) => void }> = ({set_cod
|
|||||||
<div className={style.name}>{file.name}</div>
|
<div className={style.name}>{file.name}</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
{isAdmin && (
|
||||||
|
<a href="/admin" className={style.adminLink}>管理控制台</a>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/loveuer/nf/nft/log"
|
"github.com/loveuer/nf/nft/log"
|
||||||
"github.com/loveuer/nf/nft/tool"
|
"github.com/loveuer/nf/nft/tool"
|
||||||
"github.com/loveuer/ushare/internal/handler"
|
"github.com/loveuer/ushare/internal/handler"
|
||||||
|
"github.com/loveuer/ushare/internal/model"
|
||||||
"github.com/loveuer/ushare/internal/opt"
|
"github.com/loveuer/ushare/internal/opt"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,11 +20,16 @@ func Start(ctx context.Context) <-chan struct{} {
|
|||||||
return c.SendStatus(http.StatusOK)
|
return c.SendStatus(http.StatusOK)
|
||||||
})
|
})
|
||||||
|
|
||||||
app.Get("/ushare/:code", handler.Fetch())
|
// Auth
|
||||||
app.Put("/api/ushare/:filename", handler.AuthVerify(), handler.ShareNew()) // 获取上传 code, 分片大小
|
|
||||||
app.Post("/api/ushare/:code", handler.ShareUpload()) // 分片上传接口
|
|
||||||
app.Post("/api/uauth/login", handler.AuthLogin())
|
app.Post("/api/uauth/login", handler.AuthLogin())
|
||||||
|
app.Get("/api/uauth/me", handler.AuthVerify(), handler.AuthMe())
|
||||||
|
|
||||||
|
// File sharing
|
||||||
|
app.Get("/ushare/:code", handler.Fetch())
|
||||||
|
app.Put("/api/ushare/:filename", handler.AuthVerify(), handler.AuthPermission(model.PermUpload), handler.ShareNew())
|
||||||
|
app.Post("/api/ushare/:code", handler.ShareUpload())
|
||||||
|
|
||||||
|
// Local sharing (WebRTC signaling)
|
||||||
{
|
{
|
||||||
api := app.Group("/api/ulocal")
|
api := app.Group("/api/ulocal")
|
||||||
api.Post("/register", handler.LocalRegister())
|
api.Post("/register", handler.LocalRegister())
|
||||||
@@ -34,7 +40,17 @@ func Start(ctx context.Context) <-chan struct{} {
|
|||||||
api.Get("/ws", handler.LocalWS())
|
api.Get("/ws", handler.LocalWS())
|
||||||
}
|
}
|
||||||
|
|
||||||
// 静态文件服务 - 作为中间件处理
|
// Admin
|
||||||
|
{
|
||||||
|
api := app.Group("/api/admin")
|
||||||
|
api.Get("/users", handler.AuthVerify(), handler.AuthPermission(model.PermUserManage), handler.AdminListUsers())
|
||||||
|
api.Post("/users", handler.AuthVerify(), handler.AuthPermission(model.PermUserManage), handler.AdminCreateUser())
|
||||||
|
api.Put("/users/:id", handler.AuthVerify(), handler.AuthPermission(model.PermUserManage), handler.AdminUpdateUser())
|
||||||
|
api.Delete("/users/:id", handler.AuthVerify(), handler.AuthPermission(model.PermUserManage), handler.AdminDeleteUser())
|
||||||
|
api.Get("/roles", handler.AuthVerify(), handler.AuthPermission(model.PermUserManage), handler.AdminListRoles())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frontend static files
|
||||||
app.Use(handler.ServeFrontendMiddleware())
|
app.Use(handler.ServeFrontendMiddleware())
|
||||||
|
|
||||||
ready := make(chan struct{})
|
ready := make(chan struct{})
|
||||||
|
|||||||
@@ -2,75 +2,149 @@ package controller
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"github.com/loveuer/ushare/internal/model"
|
"strings"
|
||||||
"github.com/loveuer/ushare/internal/opt"
|
|
||||||
"github.com/loveuer/ushare/internal/pkg/tool"
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/loveuer/nf/nft/log"
|
||||||
|
"github.com/loveuer/ushare/internal/model"
|
||||||
|
"github.com/loveuer/ushare/internal/opt"
|
||||||
|
"github.com/loveuer/ushare/internal/pkg/db"
|
||||||
|
"github.com/loveuer/ushare/internal/pkg/tool"
|
||||||
|
"github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
type userManager struct {
|
type userManager struct {
|
||||||
sync.Mutex
|
sync.Mutex
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
um map[string]*model.User
|
sessions map[string]*model.Session
|
||||||
}
|
}
|
||||||
|
|
||||||
func (um *userManager) Login(username string, password string) (*model.User, error) {
|
var UserManager = &userManager{
|
||||||
var (
|
sessions: make(map[string]*model.Session),
|
||||||
now = time.Now()
|
|
||||||
)
|
|
||||||
|
|
||||||
if username != opt.Cfg.Username {
|
|
||||||
return nil, errors.New("账号或密码错误")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !tool.ComparePassword(password, opt.Cfg.Password) {
|
func (um *userManager) seed(ctx context.Context) error {
|
||||||
return nil, errors.New("账号或密码错误")
|
// Seed default roles if they don't exist
|
||||||
|
defaultRoles := []model.Role{
|
||||||
|
{
|
||||||
|
Name: model.RoleAdmin,
|
||||||
|
Label: "管理员",
|
||||||
|
Permissions: strings.Join([]string{model.PermUserManage, model.PermUpload, model.PermTokenManage}, ","),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: model.RoleUser,
|
||||||
|
Label: "用户",
|
||||||
|
Permissions: model.PermUpload,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
op := &model.User{
|
for i := range defaultRoles {
|
||||||
Id: 1,
|
role := &defaultRoles[i]
|
||||||
|
var existing model.Role
|
||||||
|
if err := db.Default.Session(ctx).Where("name = ?", role.Name).First(&existing).Error; err != nil {
|
||||||
|
if err := db.Default.Session(ctx).Create(role).Error; err != nil {
|
||||||
|
return errors.Wrap(err, "seed role failed")
|
||||||
|
}
|
||||||
|
log.Debug("controller.userManager.seed: created role %s", role.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed default admin user only if no users exist
|
||||||
|
var count int64
|
||||||
|
db.Default.Session(ctx).Model(&model.User{}).Count(&count)
|
||||||
|
if count > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var adminRole model.Role
|
||||||
|
if err := db.Default.Session(ctx).Where("name = ?", model.RoleAdmin).First(&adminRole).Error; err != nil {
|
||||||
|
return errors.Wrap(err, "get admin role failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
username := opt.Cfg.Username
|
||||||
|
if username == "" {
|
||||||
|
username = "admin"
|
||||||
|
}
|
||||||
|
|
||||||
|
// opt.Cfg.Password is already hashed by opt.Init(); store it directly.
|
||||||
|
adminUser := &model.User{
|
||||||
Username: username,
|
Username: username,
|
||||||
|
Password: opt.Cfg.Password,
|
||||||
|
RoleID: adminRole.ID,
|
||||||
|
Active: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Default.Session(ctx).Create(adminUser).Error; err != nil {
|
||||||
|
return errors.Wrap(err, "seed admin user failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug("controller.userManager.seed: created admin user %s", username)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (um *userManager) Login(username, password string) (*model.Session, error) {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
user := new(model.User)
|
||||||
|
if err := db.Default.Session().
|
||||||
|
Where("username = ? AND active = ?", username, true).
|
||||||
|
Preload("Role").
|
||||||
|
First(user).Error; err != nil {
|
||||||
|
return nil, errors.New("账号或密码错误")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !tool.ComparePassword(password, user.Password) {
|
||||||
|
return nil, errors.New("账号或密码错误")
|
||||||
|
}
|
||||||
|
|
||||||
|
session := &model.Session{
|
||||||
|
UserID: user.ID,
|
||||||
|
Username: user.Username,
|
||||||
|
Role: user.Role.Name,
|
||||||
|
RoleLabel: user.Role.Label,
|
||||||
|
Permissions: user.Role.PermissionList(),
|
||||||
LoginAt: now.Unix(),
|
LoginAt: now.Unix(),
|
||||||
Token: tool.RandomString(32),
|
Token: tool.RandomString(32),
|
||||||
}
|
}
|
||||||
|
|
||||||
um.Lock()
|
um.Lock()
|
||||||
defer um.Unlock()
|
defer um.Unlock()
|
||||||
um.um[op.Token] = op
|
um.sessions[session.Token] = session
|
||||||
|
|
||||||
return op, nil
|
return session, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (um *userManager) Verify(token string) (*model.User, error) {
|
func (um *userManager) Verify(token string) (*model.Session, error) {
|
||||||
um.Lock()
|
um.Lock()
|
||||||
defer um.Unlock()
|
defer um.Unlock()
|
||||||
|
|
||||||
op, ok := um.um[token]
|
session, ok := um.sessions[token]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, errors.New("未登录或凭证已失效, 请重新登录")
|
return nil, errors.New("未登录或凭证已失效, 请重新登录")
|
||||||
}
|
}
|
||||||
|
|
||||||
return op, nil
|
return session, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (um *userManager) Start(ctx context.Context) {
|
func (um *userManager) Start(ctx context.Context) {
|
||||||
um.ctx = ctx
|
um.ctx = ctx
|
||||||
|
|
||||||
|
if err := um.seed(ctx); err != nil {
|
||||||
|
log.Fatal("controller.userManager.Start: seed failed: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
|
|
||||||
ticker := time.NewTicker(time.Minute)
|
ticker := time.NewTicker(time.Minute)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-um.ctx.Done():
|
case <-um.ctx.Done():
|
||||||
return
|
return
|
||||||
case now := <-ticker.C:
|
case now := <-ticker.C:
|
||||||
um.Lock()
|
um.Lock()
|
||||||
for _, op := range um.um {
|
for token, session := range um.sessions {
|
||||||
if now.Sub(time.UnixMilli(op.LoginAt)) > 8*time.Hour {
|
if now.Unix()-session.LoginAt > 8*3600 {
|
||||||
delete(um.um, op.Token)
|
delete(um.sessions, token)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
um.Unlock()
|
um.Unlock()
|
||||||
@@ -78,9 +152,3 @@ func (um *userManager) Start(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
|
||||||
UserManager = &userManager{
|
|
||||||
um: make(map[string]*model.User),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|||||||
211
internal/handler/admin.go
Normal file
211
internal/handler/admin.go
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/loveuer/nf"
|
||||||
|
"github.com/loveuer/nf/nft/log"
|
||||||
|
"github.com/loveuer/ushare/internal/model"
|
||||||
|
"github.com/loveuer/ushare/internal/pkg/db"
|
||||||
|
"github.com/loveuer/ushare/internal/pkg/tool"
|
||||||
|
"github.com/spf13/cast"
|
||||||
|
)
|
||||||
|
|
||||||
|
func AdminListUsers() nf.HandlerFunc {
|
||||||
|
return func(c *nf.Ctx) error {
|
||||||
|
var users []model.User
|
||||||
|
if err := db.Default.Session().Preload("Role").Find(&users).Error; err != nil {
|
||||||
|
log.Error("handler.AdminListUsers: %s", err.Error())
|
||||||
|
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "查询失败"})
|
||||||
|
}
|
||||||
|
return c.Status(http.StatusOK).JSON(map[string]any{"data": users})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func AdminCreateUser() nf.HandlerFunc {
|
||||||
|
return func(c *nf.Ctx) error {
|
||||||
|
type Req struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
RoleID uint `json:"role_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var req Req
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "参数错误"})
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Username = strings.TrimSpace(req.Username)
|
||||||
|
if req.Username == "" {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "用户名不能为空"})
|
||||||
|
}
|
||||||
|
if req.Password == "" {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "密码不能为空"})
|
||||||
|
}
|
||||||
|
if req.RoleID == 0 {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "角色不能为空"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tool.CheckPassword(req.Password); err != nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
db.Default.Session().Model(&model.User{}).Where("username = ?", req.Username).Count(&count)
|
||||||
|
if count > 0 {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "用户名已存在"})
|
||||||
|
}
|
||||||
|
|
||||||
|
user := &model.User{
|
||||||
|
Username: req.Username,
|
||||||
|
Password: tool.NewPassword(req.Password),
|
||||||
|
RoleID: req.RoleID,
|
||||||
|
Active: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Default.Session().Create(user).Error; err != nil {
|
||||||
|
log.Error("handler.AdminCreateUser: %s", err.Error())
|
||||||
|
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "创建用户失败"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Default.Session().Preload("Role").First(user, user.ID).Error; err != nil {
|
||||||
|
log.Error("handler.AdminCreateUser: preload role: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(http.StatusOK).JSON(map[string]any{"data": user})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func AdminUpdateUser() nf.HandlerFunc {
|
||||||
|
return func(c *nf.Ctx) error {
|
||||||
|
type Req struct {
|
||||||
|
RoleID *uint `json:"role_id"`
|
||||||
|
Active *bool `json:"active"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := cast.ToUintE(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "无效的用户ID"})
|
||||||
|
}
|
||||||
|
|
||||||
|
var req Req
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "参数错误"})
|
||||||
|
}
|
||||||
|
|
||||||
|
session := c.Locals("user").(*model.Session)
|
||||||
|
|
||||||
|
user := new(model.User)
|
||||||
|
if err := db.Default.Session().Preload("Role").First(user, id).Error; err != nil {
|
||||||
|
return c.Status(http.StatusNotFound).JSON(map[string]string{"msg": "用户不存在"})
|
||||||
|
}
|
||||||
|
|
||||||
|
updates := map[string]any{}
|
||||||
|
|
||||||
|
if req.RoleID != nil && *req.RoleID != user.RoleID {
|
||||||
|
var newRole model.Role
|
||||||
|
if err := db.Default.Session().First(&newRole, *req.RoleID).Error; err != nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "无效的角色"})
|
||||||
|
}
|
||||||
|
// If demoting from admin, ensure at least one other active admin remains
|
||||||
|
if user.Role.Name == model.RoleAdmin && newRole.Name != model.RoleAdmin {
|
||||||
|
var adminCount int64
|
||||||
|
db.Default.Session().Model(&model.User{}).
|
||||||
|
Where("role_id = ? AND active = ? AND id != ?", user.RoleID, true, id).
|
||||||
|
Count(&adminCount)
|
||||||
|
if adminCount == 0 {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "无法更改角色: 系统中至少需要一个管理员"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updates["role_id"] = *req.RoleID
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Active != nil && *req.Active != user.Active {
|
||||||
|
if user.ID == session.UserID && !*req.Active {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "不能禁用自己的账号"})
|
||||||
|
}
|
||||||
|
if user.Role.Name == model.RoleAdmin && !*req.Active {
|
||||||
|
var adminCount int64
|
||||||
|
db.Default.Session().Model(&model.User{}).
|
||||||
|
Where("role_id = ? AND active = ? AND id != ?", user.RoleID, true, id).
|
||||||
|
Count(&adminCount)
|
||||||
|
if adminCount == 0 {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "无法禁用: 系统中至少需要一个启用的管理员"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updates["active"] = *req.Active
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Password != "" {
|
||||||
|
if err := tool.CheckPassword(req.Password); err != nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": err.Error()})
|
||||||
|
}
|
||||||
|
updates["password"] = tool.NewPassword(req.Password)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(updates) == 0 {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "没有需要更新的字段"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Default.Session().Model(user).Updates(updates).Error; err != nil {
|
||||||
|
log.Error("handler.AdminUpdateUser: %s", err.Error())
|
||||||
|
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "更新失败"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Default.Session().Preload("Role").First(user, user.ID).Error; err != nil {
|
||||||
|
log.Error("handler.AdminUpdateUser: preload: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(http.StatusOK).JSON(map[string]any{"data": user})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func AdminDeleteUser() nf.HandlerFunc {
|
||||||
|
return func(c *nf.Ctx) error {
|
||||||
|
id, err := cast.ToUintE(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "无效的用户ID"})
|
||||||
|
}
|
||||||
|
|
||||||
|
session := c.Locals("user").(*model.Session)
|
||||||
|
if session.UserID == id {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "不能删除自己的账号"})
|
||||||
|
}
|
||||||
|
|
||||||
|
user := new(model.User)
|
||||||
|
if err := db.Default.Session().Preload("Role").First(user, id).Error; err != nil {
|
||||||
|
return c.Status(http.StatusNotFound).JSON(map[string]string{"msg": "用户不存在"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent deleting the last admin
|
||||||
|
if user.Role.Name == model.RoleAdmin {
|
||||||
|
var adminCount int64
|
||||||
|
db.Default.Session().Model(&model.User{}).
|
||||||
|
Where("role_id = ? AND id != ?", user.RoleID, id).
|
||||||
|
Count(&adminCount)
|
||||||
|
if adminCount == 0 {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "无法删除最后一个管理员"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Default.Session().Delete(user).Error; err != nil {
|
||||||
|
log.Error("handler.AdminDeleteUser: %s", err.Error())
|
||||||
|
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "删除失败"})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(http.StatusOK).JSON(map[string]any{"data": "ok"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func AdminListRoles() nf.HandlerFunc {
|
||||||
|
return func(c *nf.Ctx) error {
|
||||||
|
var roles []model.Role
|
||||||
|
if err := db.Default.Session().Find(&roles).Error; err != nil {
|
||||||
|
log.Error("handler.AdminListRoles: %s", err.Error())
|
||||||
|
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "查询失败"})
|
||||||
|
}
|
||||||
|
return c.Status(http.StatusOK).JSON(map[string]any{"data": roles})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,11 +2,11 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
"github.com/loveuer/nf"
|
"github.com/loveuer/nf"
|
||||||
"github.com/loveuer/ushare/internal/controller"
|
"github.com/loveuer/ushare/internal/controller"
|
||||||
"github.com/loveuer/ushare/internal/model"
|
"github.com/loveuer/ushare/internal/model"
|
||||||
"github.com/loveuer/ushare/internal/opt"
|
|
||||||
"net/http"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func AuthVerify() nf.HandlerFunc {
|
func AuthVerify() nf.HandlerFunc {
|
||||||
@@ -14,33 +14,54 @@ func AuthVerify() nf.HandlerFunc {
|
|||||||
if token = c.Get("Authorization"); token != "" {
|
if token = c.Get("Authorization"); token != "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
token = c.Cookies("ushare")
|
token = c.Cookies("ushare")
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
return func(c *nf.Ctx) error {
|
return func(c *nf.Ctx) error {
|
||||||
if opt.Cfg.Username == "" || opt.Cfg.Password == "" {
|
|
||||||
return c.Next()
|
|
||||||
}
|
|
||||||
|
|
||||||
token := tokenFn(c)
|
token := tokenFn(c)
|
||||||
if token == "" {
|
if token == "" {
|
||||||
return c.Status(http.StatusUnauthorized).JSON(map[string]string{"error": "unauthorized"})
|
return c.Status(http.StatusUnauthorized).JSON(map[string]string{"error": "unauthorized"})
|
||||||
}
|
}
|
||||||
|
|
||||||
op, err := controller.UserManager.Verify(token)
|
session, err := controller.UserManager.Verify(token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(http.StatusUnauthorized).JSON(map[string]string{"error": "unauthorized", "msg": err.Error()})
|
return c.Status(http.StatusUnauthorized).JSON(map[string]string{"error": "unauthorized", "msg": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Locals("user", op)
|
c.Locals("user", session)
|
||||||
|
|
||||||
return c.Next()
|
return c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func AuthPermission(perm string) nf.HandlerFunc {
|
||||||
|
return func(c *nf.Ctx) error {
|
||||||
|
session, ok := c.Locals("user").(*model.Session)
|
||||||
|
if !ok || session == nil {
|
||||||
|
return c.Status(http.StatusUnauthorized).JSON(map[string]string{"error": "unauthorized"})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range session.Permissions {
|
||||||
|
if p == perm {
|
||||||
|
return c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(http.StatusForbidden).JSON(map[string]string{"error": "forbidden", "msg": "权限不足"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func AuthMe() nf.HandlerFunc {
|
||||||
|
return func(c *nf.Ctx) error {
|
||||||
|
session, ok := c.Locals("user").(*model.Session)
|
||||||
|
if !ok || session == nil {
|
||||||
|
return c.Status(http.StatusUnauthorized).JSON(map[string]string{"error": "unauthorized"})
|
||||||
|
}
|
||||||
|
return c.Status(http.StatusOK).JSON(map[string]any{"data": session})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func AuthLogin() nf.HandlerFunc {
|
func AuthLogin() nf.HandlerFunc {
|
||||||
return func(c *nf.Ctx) error {
|
return func(c *nf.Ctx) error {
|
||||||
type Req struct {
|
type Req struct {
|
||||||
@@ -51,20 +72,20 @@ func AuthLogin() nf.HandlerFunc {
|
|||||||
var (
|
var (
|
||||||
err error
|
err error
|
||||||
req Req
|
req Req
|
||||||
op *model.User
|
session *model.Session
|
||||||
)
|
)
|
||||||
|
|
||||||
if err = c.BodyParser(&req); err != nil {
|
if err = c.BodyParser(&req); err != nil {
|
||||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "错误的用户名或密码<1>"})
|
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "错误的用户名或密码<1>"})
|
||||||
}
|
}
|
||||||
|
|
||||||
if op, err = controller.UserManager.Login(req.Username, req.Password); err != nil {
|
if session, err = controller.UserManager.Login(req.Username, req.Password); err != nil {
|
||||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": err.Error()})
|
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
header := fmt.Sprintf("ushare=%s; Path=/; Max-Age=%d", op.Token, 8*3600)
|
header := fmt.Sprintf("ushare=%s; Path=/; Max-Age=%d", session.Token, 8*3600)
|
||||||
c.SetHeader("Set-Cookie", header)
|
c.SetHeader("Set-Cookie", header)
|
||||||
|
|
||||||
return c.Status(http.StatusOK).JSON(map[string]any{"data": op})
|
return c.Status(http.StatusOK).JSON(map[string]any{"data": session})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
44
internal/model/role.go
Normal file
44
internal/model/role.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
PermUserManage = "user_manage"
|
||||||
|
PermUpload = "upload"
|
||||||
|
PermTokenManage = "token_manage"
|
||||||
|
|
||||||
|
RoleAdmin = "admin"
|
||||||
|
RoleUser = "user"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Role struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
Name string `gorm:"uniqueIndex;not null" json:"name"`
|
||||||
|
Label string `gorm:"not null" json:"label"`
|
||||||
|
Permissions string `gorm:"not null" json:"permissions"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Role) HasPermission(perm string) bool {
|
||||||
|
for _, p := range r.PermissionList() {
|
||||||
|
if p == perm {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Role) PermissionList() []string {
|
||||||
|
list := make([]string, 0)
|
||||||
|
for _, p := range strings.Split(r.Permissions, ",") {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p != "" {
|
||||||
|
list = append(list, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
@@ -1,10 +1,27 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// User is the GORM database model for persistent user storage.
|
||||||
type User struct {
|
type User struct {
|
||||||
Id int `json:"id"`
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
Username string `gorm:"uniqueIndex;not null" json:"username"`
|
||||||
|
Password string `gorm:"not null" json:"-"`
|
||||||
|
RoleID uint `gorm:"not null" json:"role_id"`
|
||||||
|
Role Role `gorm:"foreignKey:RoleID" json:"role"`
|
||||||
|
Active bool `gorm:"default:true" json:"active"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session is the in-memory representation of an authenticated user.
|
||||||
|
// It is created on login and stored in the UserManager session map.
|
||||||
|
type Session struct {
|
||||||
|
UserID uint `json:"user_id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Key string `json:"key"`
|
Role string `json:"role"`
|
||||||
Password string `json:"-"`
|
RoleLabel string `json:"role_label"`
|
||||||
|
Permissions []string `json:"permissions"`
|
||||||
LoginAt int64 `json:"login_at"`
|
LoginAt int64 `json:"login_at"`
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ func (c *Client) Session(ctxs ...context.Context) *gorm.DB {
|
|||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) Migrate(models ...interface{}) error {
|
||||||
|
return c.cli.AutoMigrate(models...)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) Close() {
|
func (c *Client) Close() {
|
||||||
d, _ := c.cli.DB()
|
d, _ := c.cli.DB()
|
||||||
d.Close()
|
d.Close()
|
||||||
|
|||||||
20
main.go
20
main.go
@@ -3,10 +3,15 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/loveuer/nf/nft/log"
|
"github.com/loveuer/nf/nft/log"
|
||||||
"github.com/loveuer/ushare/internal/api"
|
"github.com/loveuer/ushare/internal/api"
|
||||||
"github.com/loveuer/ushare/internal/controller"
|
"github.com/loveuer/ushare/internal/controller"
|
||||||
|
"github.com/loveuer/ushare/internal/model"
|
||||||
"github.com/loveuer/ushare/internal/opt"
|
"github.com/loveuer/ushare/internal/opt"
|
||||||
|
"github.com/loveuer/ushare/internal/pkg/db"
|
||||||
"github.com/loveuer/ushare/internal/pkg/tool"
|
"github.com/loveuer/ushare/internal/pkg/tool"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
@@ -32,6 +37,21 @@ func main() {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
opt.Init(ctx)
|
opt.Init(ctx)
|
||||||
|
|
||||||
|
if err := os.MkdirAll(opt.Cfg.DataPath, 0755); err != nil {
|
||||||
|
log.Fatal("main: create data path failed: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
dbPath := filepath.Join(opt.Cfg.DataPath, ".ushare.db")
|
||||||
|
if err := db.Init(ctx, "sqlite::"+dbPath); err != nil {
|
||||||
|
log.Fatal("main: init db failed: %s", err.Error())
|
||||||
|
}
|
||||||
|
log.Debug("main: db initialized at %s", dbPath)
|
||||||
|
|
||||||
|
if err := db.Default.Migrate(&model.Role{}, &model.User{}); err != nil {
|
||||||
|
log.Fatal("main: db migrate failed: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
controller.UserManager.Start(ctx)
|
controller.UserManager.Start(ctx)
|
||||||
controller.MetaManager.Start(ctx)
|
controller.MetaManager.Start(ctx)
|
||||||
controller.RoomController.Start(ctx)
|
controller.RoomController.Start(ctx)
|
||||||
|
|||||||
Reference in New Issue
Block a user