feat(auth): implement role-based access control (RBAC)

Add roles, permissions, and user_roles tables with default seed data
Protect backend API endpoints with granular permission checks
Add admin UI for managing users, roles, and permissions
Update frontend views to conditionally render actions based on permissions
This commit is contained in:
2026-05-03 11:16:58 +08:00
parent 05898f9441
commit 05f531ddf2
26 changed files with 2384 additions and 228 deletions

View File

@@ -35,20 +35,31 @@ function inDevBadge() {
return { label: t('common.inDev'), tone: 'info' as const };
}
const navItems = computed(() => [
{ label: t('nav.pokemon'), to: '/pokemon', icon: iconPokemon },
{ label: t('nav.habitats'), to: '/habitats', icon: iconHabitat },
{ label: t('nav.items'), to: '/items', icon: iconItem },
{ label: t('nav.recipes'), to: '/recipes', icon: iconRecipe },
{ label: t('nav.dish'), to: '/dish', icon: iconDish, badge: inDevBadge() },
{ label: t('nav.events'), to: '/events', icon: iconEvent, badge: inDevBadge() },
{ label: t('nav.actions'), to: '/actions', icon: iconAction, badge: inDevBadge() },
{ label: t('nav.dreamIsland'), to: '/dream-island', icon: iconDreamIsland, badge: inDevBadge() },
{ label: t('nav.clothes'), to: '/clothes', icon: iconClothes, badge: inDevBadge() },
{ label: t('nav.checklist'), to: '/checklist', icon: iconChecklist },
{ label: t('nav.life'), to: '/life', icon: iconLife },
{ label: t('nav.admin'), to: '/admin', icon: iconAdmin }
]);
function can(permissionKey: string) {
return currentUser.value?.permissions.includes(permissionKey) === true;
}
const navItems = computed(() => {
const items = [
{ label: t('nav.pokemon'), to: '/pokemon', icon: iconPokemon },
{ label: t('nav.habitats'), to: '/habitats', icon: iconHabitat },
{ label: t('nav.items'), to: '/items', icon: iconItem },
{ label: t('nav.recipes'), to: '/recipes', icon: iconRecipe },
{ label: t('nav.dish'), to: '/dish', icon: iconDish, badge: inDevBadge() },
{ label: t('nav.events'), to: '/events', icon: iconEvent, badge: inDevBadge() },
{ label: t('nav.actions'), to: '/actions', icon: iconAction, badge: inDevBadge() },
{ label: t('nav.dreamIsland'), to: '/dream-island', icon: iconDreamIsland, badge: inDevBadge() },
{ label: t('nav.clothes'), to: '/clothes', icon: iconClothes, badge: inDevBadge() },
{ label: t('nav.checklist'), to: '/checklist', icon: iconChecklist },
{ label: t('nav.life'), to: '/life', icon: iconLife }
];
if (can('admin.access')) {
items.push({ label: t('nav.admin'), to: '/admin', icon: iconAdmin });
}
return items;
});
async function loadCurrentUser() {
if (!getAuthToken()) {

View File

@@ -36,7 +36,11 @@ const commentMaxLength = 1000;
let requestId = 0;
let removeAuthListener: (() => void) | null = null;
const canComment = computed(() => currentUser.value?.emailVerified === true);
function can(permissionKey: string) {
return currentUser.value?.permissions.includes(permissionKey) === true;
}
const canComment = computed(() => can('discussions.comments.create'));
const charactersLeft = computed(() => Math.max(0, commentMaxLength - body.value.length));
const commentTotal = computed(() => comments.value.reduce((total, comment) => total + 1 + comment.replies.length, 0));
@@ -108,7 +112,11 @@ function clearCommentError(key: string) {
}
function canManageComment(comment: EntityDiscussionComment) {
return !comment.deleted && currentUser.value?.id === comment.author?.id;
return (
!comment.deleted &&
((currentUser.value?.id === comment.author?.id && can('discussions.comments.delete')) ||
can('discussions.comments.delete-any'))
);
}
function commentAuthorName(comment: EntityDiscussionComment) {

View File

@@ -15,6 +15,7 @@ const props = withDefaults(
currentImage?: EntityImage | null;
history?: EntityImageUpload[];
disabled?: boolean;
allowUpload?: boolean;
showPreview?: boolean;
}>(),
{
@@ -22,6 +23,7 @@ const props = withDefaults(
currentImage: null,
history: () => [],
disabled: false,
allowUpload: true,
showPreview: true
}
);
@@ -39,7 +41,7 @@ const uploadBusy = ref(false);
const localUploads = ref<EntityImageUpload[]>([]);
const imageLabel = computed(() => props.label || t('media.image'));
const uploadDisabled = computed(() => props.disabled || uploadBusy.value || props.entityName.trim() === '');
const uploadDisabled = computed(() => !props.allowUpload || props.disabled || uploadBusy.value || props.entityName.trim() === '');
const imageOptions = computed<EntityImage[]>(() => {
const images = [
...localUploads.value,
@@ -115,6 +117,7 @@ async function uploadImage(event: Event) {
<span class="field-label">{{ imageLabel }}</span>
<div class="image-upload-field__actions">
<input
v-if="allowUpload"
ref="fileInput"
class="image-upload-field__input"
type="file"
@@ -122,7 +125,7 @@ async function uploadImage(event: Event) {
:disabled="uploadDisabled"
@change="uploadImage"
/>
<button type="button" class="ui-button ui-button--blue ui-button--small" :disabled="uploadDisabled" @click="openFilePicker">
<button v-if="allowUpload" type="button" class="ui-button ui-button--blue ui-button--small" :disabled="uploadDisabled" @click="openFilePicker">
<Icon :icon="iconUpload" class="ui-icon" aria-hidden="true" />
{{ uploadBusy ? t('media.uploading') : t('media.uploadImage') }}
</button>

View File

@@ -24,20 +24,20 @@ export const router = createRouter({
routes: [
{ path: '/', redirect: '/pokemon' },
{ path: '/pokemon', name: 'pokemon-list', component: PokemonList },
{ path: '/pokemon/new', name: 'pokemon-new', component: PokemonList, meta: { requiresVerified: true, editorModal: true } },
{ path: '/pokemon/:id/edit', name: 'pokemon-edit', component: PokemonDetail, meta: { requiresVerified: true, editorModal: true } },
{ path: '/pokemon/new', name: 'pokemon-new', component: PokemonList, meta: { requiredPermission: 'pokemon.create', editorModal: true } },
{ path: '/pokemon/:id/edit', name: 'pokemon-edit', component: PokemonDetail, meta: { requiredPermission: 'pokemon.update', editorModal: true } },
{ path: '/pokemon/:id', name: 'pokemon-detail', component: PokemonDetail },
{ path: '/habitats', name: 'habitat-list', component: HabitatList },
{ path: '/habitats/new', name: 'habitat-new', component: HabitatList, meta: { requiresVerified: true, editorModal: true } },
{ path: '/habitats/:id/edit', name: 'habitat-edit', component: HabitatDetail, meta: { requiresVerified: true, editorModal: true } },
{ path: '/habitats/new', name: 'habitat-new', component: HabitatList, meta: { requiredPermission: 'habitats.create', editorModal: true } },
{ path: '/habitats/:id/edit', name: 'habitat-edit', component: HabitatDetail, meta: { requiredPermission: 'habitats.update', editorModal: true } },
{ path: '/habitats/:id', name: 'habitat-detail', component: HabitatDetail },
{ path: '/items', name: 'item-list', component: ItemsList },
{ path: '/items/new', name: 'item-new', component: ItemsList, meta: { requiresVerified: true, editorModal: true } },
{ path: '/items/:id/edit', name: 'item-edit', component: ItemDetail, meta: { requiresVerified: true, editorModal: true } },
{ path: '/items/new', name: 'item-new', component: ItemsList, meta: { requiredPermission: 'items.create', editorModal: true } },
{ path: '/items/:id/edit', name: 'item-edit', component: ItemDetail, meta: { requiredPermission: 'items.update', editorModal: true } },
{ path: '/items/:id', name: 'item-detail', component: ItemDetail },
{ path: '/recipes', name: 'recipe-list', component: RecipeList },
{ path: '/recipes/new', name: 'recipe-new', component: RecipeList, meta: { requiresVerified: true, editorModal: true } },
{ path: '/recipes/:id/edit', name: 'recipe-edit', component: RecipeDetail, meta: { requiresVerified: true, editorModal: true } },
{ path: '/recipes/new', name: 'recipe-new', component: RecipeList, meta: { requiredPermission: 'recipes.create', editorModal: true } },
{ path: '/recipes/:id/edit', name: 'recipe-edit', component: RecipeDetail, meta: { requiredPermission: 'recipes.update', editorModal: true } },
{ path: '/recipes/:id', name: 'recipe-detail', component: RecipeDetail },
{ path: '/dish', name: 'dish', component: ComingSoonView, props: { page: 'dish' } },
{ path: '/events', name: 'events', component: ComingSoonView, props: { page: 'events' } },
@@ -46,7 +46,7 @@ export const router = createRouter({
{ path: '/clothes', name: 'clothes', component: ComingSoonView, props: { page: 'clothes' } },
{ path: '/checklist', component: DailyChecklistView },
{ path: '/life', component: LifeView },
{ path: '/admin', component: AdminView, meta: { requiresVerified: true } },
{ path: '/admin', component: AdminView, meta: { requiredPermission: 'admin.access' } },
{ path: '/profile', component: UserProfileView, meta: { requiresAuth: true } },
{ path: '/login', component: LoginView },
{ path: '/forgot-password', component: ForgotPasswordView },
@@ -62,7 +62,15 @@ export const router = createRouter({
});
router.beforeEach(async (to) => {
const requiresVerified = to.matched.some((record) => record.meta.requiresVerified === true);
const requiredPermissions = to.matched
.map((record) => record.meta.requiredPermission)
.filter((permission): permission is string => typeof permission === 'string');
const requiredAnyPermissions = to.matched.flatMap((record) =>
Array.isArray(record.meta.requiredAnyPermission)
? record.meta.requiredAnyPermission.filter((permission): permission is string => typeof permission === 'string')
: []
);
const requiresVerified = to.matched.some((record) => record.meta.requiresVerified === true) || requiredPermissions.length > 0 || requiredAnyPermissions.length > 0;
const requiresAuth = requiresVerified || to.matched.some((record) => record.meta.requiresAuth === true);
if (!requiresAuth) {
@@ -75,7 +83,19 @@ router.beforeEach(async (to) => {
try {
const response = await api.me();
return !requiresVerified || response.user.emailVerified ? true : { path: '/login', query: { redirect: to.fullPath } };
if (requiresVerified && !response.user.emailVerified) {
return { path: '/login', query: { redirect: to.fullPath } };
}
const permissionSet = new Set(response.user.permissions);
if (requiredPermissions.some((permission) => !permissionSet.has(permission))) {
return { path: '/pokemon' };
}
if (requiredAnyPermissions.length && !requiredAnyPermissions.some((permission) => permissionSet.has(permission))) {
return { path: '/pokemon' };
}
return true;
} catch {
setAuthToken(null);
return { path: '/login', query: { redirect: to.fullPath } };

View File

@@ -314,6 +314,8 @@ export interface AuthUser {
email: string;
displayName: string;
emailVerified: boolean;
roles: RoleSummary[];
permissions: string[];
}
export interface ReferralSummary {
@@ -322,6 +324,52 @@ export interface ReferralSummary {
verifiedReferralCount: number;
}
export interface RoleSummary {
id: number;
key: string;
name: string;
level: number;
}
export interface RoleDetail extends RoleSummary {
description: string;
enabled: boolean;
systemRole: boolean;
permissionIds: number[];
}
export interface Permission {
id: number;
key: string;
name: string;
description: string;
category: string;
enabled: boolean;
systemPermission: boolean;
}
export interface AdminUser extends AuthUser {
roleIds: number[];
createdAt: string;
updatedAt: string;
}
export interface RolePayload {
key?: string;
name: string;
description: string;
level: number;
enabled: boolean;
}
export interface PermissionPayload {
key?: string;
name: string;
description: string;
category: string;
enabled: boolean;
}
export interface UserProfilePayload {
displayName: string;
}
@@ -646,6 +694,22 @@ export const api = {
updateMe: (payload: UserProfilePayload) => sendJson<{ user: AuthUser }>('/api/auth/me', 'PATCH', payload),
referral: () => getJson<{ referral: ReferralSummary }>('/api/auth/referral'),
logout: () => postEmpty('/api/auth/logout'),
adminUsers: () => getJson<AdminUser[]>('/api/admin/users'),
updateAdminUserRoles: (id: string | number, roleIds: number[]) =>
sendJson<AdminUser[]>(`/api/admin/users/${id}/roles`, 'PUT', { roleIds }),
roles: () => getJson<RoleDetail[]>('/api/admin/roles'),
createRole: (payload: RolePayload & { key: string }) => sendJson<RoleDetail[]>('/api/admin/roles', 'POST', payload),
updateRole: (id: string | number, payload: RolePayload) =>
sendJson<RoleDetail[]>(`/api/admin/roles/${id}`, 'PUT', payload),
updateRolePermissions: (id: string | number, permissionIds: number[]) =>
sendJson<RoleDetail[]>(`/api/admin/roles/${id}/permissions`, 'PUT', { permissionIds }),
deleteRole: (id: string | number) => deleteJson(`/api/admin/roles/${id}`),
permissions: () => getJson<Permission[]>('/api/admin/permissions'),
createPermission: (payload: PermissionPayload & { key: string }) =>
sendJson<Permission[]>('/api/admin/permissions', 'POST', payload),
updatePermission: (id: string | number, payload: PermissionPayload) =>
sendJson<Permission[]>(`/api/admin/permissions/${id}`, 'PUT', payload),
deletePermission: (id: string | number) => deleteJson(`/api/admin/permissions/${id}`),
options: () => getJson<Options>('/api/options'),
dailyChecklist: () => getJson<DailyChecklistItem[]>('/api/daily-checklist'),
lifePosts: (params: LifePostsParams = {}) =>

View File

@@ -2828,6 +2828,87 @@ button:disabled,
overflow-wrap: anywhere;
}
.access-list li {
align-items: flex-start;
}
.access-row,
.access-modal-heading {
min-width: 0;
display: grid;
gap: 7px;
}
.access-row strong,
.access-modal-heading strong {
color: var(--ink);
overflow-wrap: anywhere;
}
.permission-groups {
display: grid;
gap: 14px;
}
.permission-group {
display: grid;
gap: 10px;
padding: 12px;
border: 1px solid var(--line);
border-radius: var(--radius-card);
background: var(--surface-soft);
}
.permission-group h3 {
margin: 0;
color: var(--ink);
font-size: 15px;
}
.permission-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 8px;
}
.permission-toggle {
min-height: 52px;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 8px;
align-items: start;
padding: 10px;
border: 1px solid var(--line);
border-radius: var(--radius-control);
background: var(--surface);
color: var(--ink-soft);
cursor: pointer;
}
.permission-toggle input {
width: 18px;
height: 18px;
margin-top: 2px;
accent-color: var(--pokemon-blue);
}
.permission-toggle strong,
.permission-toggle small {
display: block;
overflow-wrap: anywhere;
}
.permission-toggle strong {
color: var(--ink);
font-size: 14px;
}
.permission-toggle small {
margin-top: 2px;
color: var(--muted);
font-size: 12px;
}
.chips {
display: flex;
flex-wrap: wrap;

View File

@@ -18,7 +18,9 @@ import {
iconEdit,
iconHabitat,
iconItem,
iconKey,
iconPokemon,
iconProfile,
iconRecipe,
iconSave,
iconTranslate,
@@ -28,24 +30,43 @@ import { defaultLocale, getCurrentLocale, loadSystemWordings, setCurrentLocale }
import {
api,
type AuthUser,
type AdminUser,
type ConfigType,
type DailyChecklistItem,
type Habitat,
type Item,
type Language,
type NamedEntity,
type Permission,
type PermissionPayload,
type Pokemon,
type Recipe,
type RoleDetail,
type RolePayload,
type Skill,
type SystemWording,
type SystemWordingSurface,
type TranslationMap
} from '../services/api';
type AdminTab = 'config' | 'languages' | 'wordings' | 'checklist' | 'pokemon' | 'items' | 'recipes' | 'habitats';
type AdminTab =
| 'users'
| 'roles'
| 'permissions'
| 'config'
| 'languages'
| 'wordings'
| 'checklist'
| 'pokemon'
| 'items'
| 'recipes'
| 'habitats';
type EditableConfig = (NamedEntity | Skill) & { hasItemDrop?: boolean };
const adminTabIcons: Record<AdminTab, AppIcon> = {
users: iconProfile,
roles: iconKey,
permissions: iconKey,
config: iconAdmin,
languages: iconTranslate,
wordings: iconTranslate,
@@ -58,16 +79,21 @@ const adminTabIcons: Record<AdminTab, AppIcon> = {
const { locale, t } = useI18n();
const tabs = computed<Array<{ key: AdminTab; label: string }>>(() => [
{ key: 'config', label: t('pages.admin.config') },
{ key: 'languages', label: t('pages.admin.languages') },
{ key: 'wordings', label: t('pages.admin.wordings') },
{ key: 'checklist', label: t('pages.admin.checklist') },
{ key: 'pokemon', label: 'Pokemon' },
{ key: 'items', label: t('pages.items.title') },
{ key: 'recipes', label: t('pages.recipes.title') },
{ key: 'habitats', label: t('pages.habitats.title') }
]);
const tabs = computed<Array<{ key: AdminTab; label: string; permission: string | string[] }>>(() =>
[
{ key: 'users' as const, label: t('pages.admin.users'), permission: 'admin.users.read' },
{ key: 'roles' as const, label: t('pages.admin.roles'), permission: 'admin.roles.read' },
{ key: 'permissions' as const, label: t('pages.admin.permissions'), permission: 'admin.permissions.read' },
{ key: 'config' as const, label: t('pages.admin.config'), permission: 'admin.config.read' },
{ key: 'languages' as const, label: t('pages.admin.languages'), permission: 'admin.languages.read' },
{ key: 'wordings' as const, label: t('pages.admin.wordings'), permission: 'admin.wordings.read' },
{ key: 'checklist' as const, label: t('pages.admin.checklist'), permission: ['checklist.create', 'checklist.update', 'checklist.delete', 'checklist.order'] },
{ key: 'pokemon' as const, label: 'Pokemon', permission: ['pokemon.order', 'pokemon.delete'] },
{ key: 'items' as const, label: t('pages.items.title'), permission: ['items.order', 'items.delete'] },
{ key: 'recipes' as const, label: t('pages.recipes.title'), permission: ['recipes.order', 'recipes.delete'] },
{ key: 'habitats' as const, label: t('pages.habitats.title'), permission: ['habitats.order', 'habitats.delete'] }
].filter((tab) => canAny(tab.permission))
);
const configTypes = computed<Array<{ key: ConfigType; label: string; supportsItemDrop?: boolean }>>(() => [
{ key: 'pokemon-types', label: t('config.pokemonTypes') },
@@ -83,6 +109,9 @@ const configTypes = computed<Array<{ key: ConfigType; label: string; supportsIte
const activeTab = ref<AdminTab>('config');
const activeConfigType = ref<ConfigType>('skills');
const userRows = ref<AdminUser[]>([]);
const roleRows = ref<RoleDetail[]>([]);
const permissionRows = ref<Permission[]>([]);
const configRows = ref<EditableConfig[]>([]);
const languageRows = ref<Language[]>([]);
const checklistRows = ref<DailyChecklistItem[]>([]);
@@ -99,11 +128,19 @@ const configForm = ref({ id: 0, name: '', translations: {} as TranslationMap, ha
const checklistForm = ref({ id: 0, title: '', translations: {} as TranslationMap });
const languageForm = ref({ code: '', name: '', enabled: true, isDefault: false, sortOrder: 0 });
const wordingForm = ref({ key: '', locale: defaultLocale, value: '', defaultValue: '', placeholders: [] as string[] });
const userRoleForm = ref({ userId: 0, roleIds: [] as number[] });
const roleForm = ref({ id: 0, key: '', name: '', description: '', level: 100, enabled: true });
const rolePermissionForm = ref({ roleId: 0, permissionIds: [] as number[] });
const permissionForm = ref({ id: 0, key: '', name: '', description: '', category: 'General', enabled: true });
const editingLanguageCode = ref('');
const configModalOpen = ref(false);
const checklistModalOpen = ref(false);
const languageModalOpen = ref(false);
const wordingModalOpen = ref(false);
const userRoleModalOpen = ref(false);
const roleModalOpen = ref(false);
const rolePermissionsModalOpen = ref(false);
const permissionModalOpen = ref(false);
const wordingLocale = ref(getCurrentLocale());
const wordingModule = ref('');
const wordingSurface = ref<SystemWordingSurface | ''>('');
@@ -143,7 +180,7 @@ const activeConfigTab = computed({
void run(loadConfig);
}
});
const canEdit = computed(() => currentUser.value?.emailVerified === true);
const canEdit = computed(() => can('admin.access'));
const showAdminSkeleton = computed(() => busy.value && !message.value && (!currentUser.value || contentLoading.value));
const canSetLanguageDefault = computed(() => languageForm.value.code === 'en');
const configModalTitle = computed(() =>
@@ -152,6 +189,21 @@ const configModalTitle = computed(() =>
const checklistModalTitle = computed(() => (checklistForm.value.id ? t('pages.checklist.editTask') : t('pages.checklist.newTask')));
const languageModalTitle = computed(() => (editingLanguageCode.value ? t('pages.admin.editLanguage') : t('pages.admin.newLanguage')));
const wordingModalTitle = computed(() => t('pages.admin.editWording'));
const roleModalTitle = computed(() => (roleForm.value.id ? t('pages.admin.editRole') : t('pages.admin.newRole')));
const permissionModalTitle = computed(() =>
permissionForm.value.id ? t('pages.admin.editPermission') : t('pages.admin.newPermission')
);
const rolePermissionsModalTitle = computed(() => t('pages.admin.rolePermissions'));
const userRoleModalTitle = computed(() => t('pages.admin.userRoles'));
const editingUser = computed(() => userRows.value.find((user) => user.id === userRoleForm.value.userId) ?? null);
const editingRole = computed(() => roleRows.value.find((role) => role.id === rolePermissionForm.value.roleId) ?? null);
const permissionGroups = computed(() => {
const groups = new Map<string, Permission[]>();
for (const permission of permissionRows.value) {
groups.set(permission.category, [...(groups.get(permission.category) ?? []), permission]);
}
return [...groups.entries()].map(([category, permissions]) => ({ category, permissions }));
});
const wordingLocaleOptions = computed(() =>
languageRows.value.length
? languageRows.value
@@ -196,10 +248,51 @@ const recipeLabel = (item: Recipe) => item.name;
const habitatKey = (item: Habitat) => item.id;
const habitatLabel = (item: Habitat) => item.name;
function can(permissionKey: string) {
return currentUser.value?.permissions.includes(permissionKey) === true;
}
function canAny(permissionKey: string | string[]) {
return Array.isArray(permissionKey) ? permissionKey.some((key) => can(key)) : can(permissionKey);
}
function dragSortLabel(name: string) {
return t('pages.admin.dragSort', { name });
}
function roleNames(roleIds: number[], fallbackRoles: AuthUser['roles'] = []) {
const names = roleIds
.map((roleId) => roleRows.value.find((role) => role.id === roleId)?.name)
.filter((name): name is string => Boolean(name));
const fallbackNames = fallbackRoles.map((role) => role.name);
const visibleNames = names.length ? names : fallbackNames;
return visibleNames.length ? visibleNames.join(', ') : t('pages.admin.noRoles');
}
function rolePermissionCount(role: RoleDetail) {
return t('pages.admin.permissionCount', { count: role.permissionIds.length });
}
function toggleUserRole(roleId: number) {
const roleIds = new Set(userRoleForm.value.roleIds);
if (roleIds.has(roleId)) {
roleIds.delete(roleId);
} else {
roleIds.add(roleId);
}
userRoleForm.value.roleIds = [...roleIds].sort((a, b) => a - b);
}
function toggleRolePermission(permissionId: number) {
const permissionIds = new Set(rolePermissionForm.value.permissionIds);
if (permissionIds.has(permissionId)) {
permissionIds.delete(permissionId);
} else {
permissionIds.add(permissionId);
}
rolePermissionForm.value.permissionIds = [...permissionIds].sort((a, b) => a - b);
}
function errorText(error: unknown, fallback: string) {
return error instanceof Error && error.message ? error.message : fallback;
}
@@ -242,6 +335,22 @@ function resetWordingForm() {
wordingForm.value = { key: '', locale: wordingLocale.value || defaultLocale, value: '', defaultValue: '', placeholders: [] };
}
function resetUserRoleForm() {
userRoleForm.value = { userId: 0, roleIds: [] };
}
function resetRoleForm() {
roleForm.value = { id: 0, key: '', name: '', description: '', level: 100, enabled: true };
}
function resetRolePermissionForm() {
rolePermissionForm.value = { roleId: 0, permissionIds: [] };
}
function resetPermissionForm() {
permissionForm.value = { id: 0, key: '', name: '', description: '', category: 'General', enabled: true };
}
function selectWordingModule(module: string) {
wordingModule.value = module;
}
@@ -291,6 +400,70 @@ function closeWordingModal() {
resetWordingForm();
}
function openUserRoles(user: AdminUser) {
userRoleForm.value = { userId: user.id, roleIds: [...user.roleIds] };
userRoleModalOpen.value = true;
}
function closeUserRoleModal() {
userRoleModalOpen.value = false;
resetUserRoleForm();
}
function openNewRole() {
resetRoleForm();
roleModalOpen.value = true;
}
function editRole(role: RoleDetail) {
roleForm.value = {
id: role.id,
key: role.key,
name: role.name,
description: role.description,
level: role.level,
enabled: role.enabled
};
roleModalOpen.value = true;
}
function closeRoleModal() {
roleModalOpen.value = false;
resetRoleForm();
}
function editRolePermissions(role: RoleDetail) {
rolePermissionForm.value = { roleId: role.id, permissionIds: [...role.permissionIds] };
rolePermissionsModalOpen.value = true;
}
function closeRolePermissionsModal() {
rolePermissionsModalOpen.value = false;
resetRolePermissionForm();
}
function openNewPermission() {
resetPermissionForm();
permissionModalOpen.value = true;
}
function editPermission(permission: Permission) {
permissionForm.value = {
id: permission.id,
key: permission.key,
name: permission.name,
description: permission.description,
category: permission.category,
enabled: permission.enabled
};
permissionModalOpen.value = true;
}
function closePermissionModal() {
permissionModalOpen.value = false;
resetPermissionForm();
}
function editLanguage(item: Language) {
editingLanguageCode.value = item.code;
languageForm.value = {
@@ -550,6 +723,26 @@ async function loadHabitats() {
habitatRows.value = await api.habitats();
}
async function loadUsers() {
if (can('admin.roles.read')) {
roleRows.value = await api.roles();
}
userRows.value = await api.adminUsers();
}
async function loadRoles() {
const [roles, permissions] = await Promise.all([
api.roles(),
can('admin.permissions.read') ? api.permissions() : Promise.resolve(permissionRows.value)
]);
roleRows.value = roles;
permissionRows.value = permissions;
}
async function loadPermissions() {
permissionRows.value = await api.permissions();
}
async function loadWordings() {
await loadLanguages();
if (!wordingLocaleOptions.value.some((language) => language.code === wordingLocale.value)) {
@@ -576,6 +769,58 @@ async function saveWording() {
});
}
async function saveUserRoles() {
await run(async () => {
userRows.value = await api.updateAdminUserRoles(userRoleForm.value.userId, userRoleForm.value.roleIds);
closeUserRoleModal();
if (can('admin.roles.read')) {
roleRows.value = await api.roles();
}
});
}
async function saveRole() {
await run(async () => {
const payload: RolePayload = {
name: roleForm.value.name,
description: roleForm.value.description,
level: roleForm.value.level,
enabled: roleForm.value.enabled
};
roleRows.value = roleForm.value.id
? await api.updateRole(roleForm.value.id, payload)
: await api.createRole({ ...payload, key: roleForm.value.key });
closeRoleModal();
});
}
async function saveRolePermissions() {
await run(async () => {
roleRows.value = await api.updateRolePermissions(rolePermissionForm.value.roleId, rolePermissionForm.value.permissionIds);
closeRolePermissionsModal();
});
}
async function savePermission() {
await run(async () => {
const payload: PermissionPayload = {
name: permissionForm.value.name,
description: permissionForm.value.description,
category: permissionForm.value.category,
enabled: permissionForm.value.enabled
};
permissionRows.value = permissionForm.value.id
? await api.updatePermission(permissionForm.value.id, payload)
: await api.createPermission({ ...payload, key: permissionForm.value.key });
closePermissionModal();
if (can('admin.roles.read')) {
roleRows.value = await api.roles();
}
});
}
async function loadCurrentTab(showSkeleton = false) {
if (showSkeleton) {
contentLoading.value = true;
@@ -583,6 +828,9 @@ async function loadCurrentTab(showSkeleton = false) {
try {
if (activeTab.value === 'config') await loadConfig();
if (activeTab.value === 'users') await loadUsers();
if (activeTab.value === 'roles') await loadRoles();
if (activeTab.value === 'permissions') await loadPermissions();
if (activeTab.value === 'languages') await loadLanguages();
if (activeTab.value === 'wordings') await loadWordings();
if (activeTab.value === 'checklist') await loadChecklist();
@@ -599,7 +847,7 @@ async function loadCurrentTab(showSkeleton = false) {
function setTab(tab: AdminTab) {
if (!canEdit.value) {
message.value = t('errors.completeEmailVerification');
message.value = t('errors.permissionDenied');
return;
}
@@ -607,6 +855,12 @@ function setTab(tab: AdminTab) {
void run(() => loadCurrentTab(true));
}
function ensureActiveTabAllowed() {
if (!tabs.value.some((tab) => tab.key === activeTab.value)) {
activeTab.value = tabs.value[0]?.key ?? 'config';
}
}
async function loadAdmin() {
const response = await api.me();
currentUser.value = response.user;
@@ -615,7 +869,12 @@ async function loadAdmin() {
message.value = t('errors.completeEmailVerification');
return;
}
if (!canEdit.value || !tabs.value.length) {
message.value = t('errors.permissionDenied');
return;
}
ensureActiveTabAllowed();
await loadCurrentTab(true);
}
@@ -678,6 +937,32 @@ async function removeHabitat(id: number) {
});
}
async function removeRole(id: number) {
await run(async () => {
await api.deleteRole(id);
if (roleForm.value.id === id) {
closeRoleModal();
}
if (rolePermissionForm.value.roleId === id) {
closeRolePermissionsModal();
}
await loadRoles();
});
}
async function removePermission(id: number) {
await run(async () => {
await api.deletePermission(id);
if (permissionForm.value.id === id) {
closePermissionModal();
}
await loadPermissions();
if (can('admin.roles.read')) {
roleRows.value = await api.roles();
}
});
}
onMounted(() => {
void run(loadAdmin);
});
@@ -710,10 +995,110 @@ onMounted(() => {
</ul>
</section>
<section v-else-if="canEdit && activeTab === 'users'" class="detail-section">
<div class="detail-section__header">
<h2>{{ t('pages.admin.users') }}</h2>
</div>
<ul v-if="userRows.length" class="row-list access-list">
<li v-for="user in userRows" :key="user.id">
<span class="access-row">
<strong>{{ user.displayName }}</strong>
<span class="meta-line">{{ user.email }}</span>
<span class="system-wording-row__meta">
<span class="config-flag">{{ user.emailVerified ? t('pages.profile.emailVerified') : t('pages.profile.emailUnverified') }}</span>
<span class="config-flag">{{ roleNames(user.roleIds, user.roles) }}</span>
</span>
</span>
<span class="row-actions">
<button v-if="can('admin.users.update') && can('admin.roles.read')" type="button" :disabled="busy" @click="openUserRoles(user)">
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
{{ t('pages.admin.userRoles') }}
</button>
</span>
</li>
</ul>
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
</section>
<section v-else-if="canEdit && activeTab === 'roles'" class="detail-section">
<div class="detail-section__header">
<h2>{{ t('pages.admin.roles') }}</h2>
<button v-if="can('admin.roles.create')" type="button" class="ui-button ui-button--primary ui-button--small" :disabled="busy" @click="openNewRole">
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
{{ t('common.new') }}
</button>
</div>
<ul v-if="roleRows.length" class="row-list access-list">
<li v-for="role in roleRows" :key="role.id">
<span class="access-row">
<strong>{{ role.name }}</strong>
<span class="meta-line">{{ role.description }}</span>
<span class="system-wording-row__meta">
<span class="config-flag">{{ role.key }}</span>
<span class="config-flag">{{ t('pages.admin.roleLevel', { level: role.level }) }}</span>
<span class="config-flag">{{ role.enabled ? t('pages.admin.enabled') : t('pages.admin.disabled') }}</span>
<span v-if="role.systemRole" class="config-flag">{{ t('pages.admin.systemRole') }}</span>
<span class="config-flag">{{ rolePermissionCount(role) }}</span>
</span>
</span>
<span class="row-actions">
<button v-if="can('admin.roles.update')" type="button" :disabled="busy" @click="editRole(role)">
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
{{ t('common.edit') }}
</button>
<button v-if="can('admin.roles.update') && can('admin.permissions.read')" type="button" :disabled="busy || role.key === 'owner'" @click="editRolePermissions(role)">
<Icon :icon="iconKey" class="ui-icon" aria-hidden="true" />
{{ t('pages.admin.rolePermissions') }}
</button>
<button v-if="can('admin.roles.delete')" type="button" :disabled="busy || role.key === 'owner'" @click="removeRole(role.id)">
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
{{ t('common.delete') }}
</button>
</span>
</li>
</ul>
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
</section>
<section v-else-if="canEdit && activeTab === 'permissions'" class="detail-section">
<div class="detail-section__header">
<h2>{{ t('pages.admin.permissions') }}</h2>
<button v-if="can('admin.permissions.create')" type="button" class="ui-button ui-button--primary ui-button--small" :disabled="busy" @click="openNewPermission">
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
{{ t('common.new') }}
</button>
</div>
<ul v-if="permissionRows.length" class="row-list access-list">
<li v-for="permission in permissionRows" :key="permission.id">
<span class="access-row">
<strong>{{ permission.name }}</strong>
<span class="meta-line">{{ permission.description }}</span>
<span class="system-wording-row__meta">
<span class="config-flag">{{ permission.key }}</span>
<span class="config-flag">{{ permission.category }}</span>
<span class="config-flag">{{ permission.enabled ? t('pages.admin.enabled') : t('pages.admin.disabled') }}</span>
<span v-if="permission.systemPermission" class="config-flag">{{ t('pages.admin.systemPermission') }}</span>
</span>
</span>
<span class="row-actions">
<button v-if="can('admin.permissions.update')" type="button" :disabled="busy" @click="editPermission(permission)">
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
{{ t('common.edit') }}
</button>
<button v-if="can('admin.permissions.delete')" type="button" :disabled="busy" @click="removePermission(permission.id)">
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
{{ t('common.delete') }}
</button>
</span>
</li>
</ul>
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
</section>
<section v-else-if="canEdit && activeTab === 'checklist'" class="detail-section">
<div class="detail-section__header">
<h2>{{ t('pages.admin.checklist') }}</h2>
<button type="button" class="ui-button ui-button--primary ui-button--small" :disabled="busy" @click="openNewChecklistItem">
<button v-if="can('checklist.create')" type="button" class="ui-button ui-button--primary ui-button--small" :disabled="busy" @click="openNewChecklistItem">
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
{{ t('common.new') }}
</button>
@@ -725,7 +1110,7 @@ onMounted(() => {
:item-key="checklistKey"
:item-label="checklistLabel"
list-key-prefix="checklist"
:disabled="busy"
:disabled="busy || !can('checklist.order')"
:handle-label="dragSortLabel"
:handle-title="t('pages.admin.dragSortTitle')"
@preview="previewChecklistOrder"
@@ -735,11 +1120,11 @@ onMounted(() => {
<template #default="{ item }">
<span class="reorderable-row-title">{{ item.title }}</span>
<span class="row-actions">
<button type="button" :disabled="busy" @click="editChecklistItem(item)">
<button v-if="can('checklist.update')" type="button" :disabled="busy" @click="editChecklistItem(item)">
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
{{ t('common.edit') }}
</button>
<button type="button" :disabled="busy" @click="removeChecklistItem(item.id)">
<button v-if="can('checklist.delete')" type="button" :disabled="busy" @click="removeChecklistItem(item.id)">
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
{{ t('common.delete') }}
</button>
@@ -752,7 +1137,7 @@ onMounted(() => {
<section v-else-if="canEdit && activeTab === 'config'" class="detail-section">
<div class="detail-section__header">
<h2>{{ t('pages.admin.config') }}</h2>
<button type="button" class="ui-button ui-button--primary ui-button--small" :disabled="busy" @click="openNewConfig">
<button v-if="can('admin.config.create')" type="button" class="ui-button ui-button--primary ui-button--small" :disabled="busy" @click="openNewConfig">
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
{{ t('common.new') }}
</button>
@@ -765,7 +1150,7 @@ onMounted(() => {
:item-key="configKey"
:item-label="configLabel"
:list-key-prefix="`config-${activeConfigType}`"
:disabled="busy"
:disabled="busy || !can('admin.config.order')"
:handle-label="dragSortLabel"
:handle-title="t('pages.admin.dragSortTitle')"
@preview="previewConfigOrder"
@@ -777,11 +1162,11 @@ onMounted(() => {
{{ item.name }}<span v-if="item.hasItemDrop" class="config-flag">{{ t('pages.admin.hasItemDrop') }}</span>
</span>
<span class="row-actions">
<button type="button" :disabled="busy" @click="editConfig(item)">
<button v-if="can('admin.config.update')" type="button" :disabled="busy" @click="editConfig(item)">
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
{{ t('common.edit') }}
</button>
<button type="button" :disabled="busy" @click="removeConfig(item.id)">
<button v-if="can('admin.config.delete')" type="button" :disabled="busy" @click="removeConfig(item.id)">
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
{{ t('common.delete') }}
</button>
@@ -794,7 +1179,7 @@ onMounted(() => {
<section v-else-if="canEdit && activeTab === 'languages'" class="detail-section">
<div class="detail-section__header">
<h2>{{ t('pages.admin.languages') }}</h2>
<button type="button" class="ui-button ui-button--primary ui-button--small" :disabled="busy" @click="openNewLanguage">
<button v-if="can('admin.languages.create')" type="button" class="ui-button ui-button--primary ui-button--small" :disabled="busy" @click="openNewLanguage">
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
{{ t('common.new') }}
</button>
@@ -805,7 +1190,7 @@ onMounted(() => {
:item-key="languageKey"
:item-label="languageLabel"
list-key-prefix="languages"
:disabled="busy"
:disabled="busy || !can('admin.languages.order')"
:handle-label="dragSortLabel"
:handle-title="t('pages.admin.dragSortTitle')"
@preview="previewLanguageOrder"
@@ -818,11 +1203,11 @@ onMounted(() => {
<span v-if="item.isDefault" class="config-flag">{{ t('pages.admin.defaultLanguage') }}</span>
</span>
<span class="row-actions">
<button type="button" @click="editLanguage(item)">
<button v-if="can('admin.languages.update')" type="button" @click="editLanguage(item)">
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
{{ t('common.edit') }}
</button>
<button type="button" :disabled="item.isDefault" @click="removeLanguage(item.code)">
<button v-if="can('admin.languages.delete')" type="button" :disabled="item.isDefault" @click="removeLanguage(item.code)">
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
{{ t('common.delete') }}
</button>
@@ -893,7 +1278,7 @@ onMounted(() => {
<span class="system-wording-row__value">{{ item.value || item.defaultValue }}</span>
</span>
<span class="row-actions">
<button type="button" :disabled="busy" @click="editWording(item)">
<button v-if="can('admin.wordings.update')" type="button" :disabled="busy" @click="editWording(item)">
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
{{ t('common.edit') }}
</button>
@@ -913,7 +1298,7 @@ onMounted(() => {
:item-key="pokemonKey"
:item-label="pokemonLabel"
list-key-prefix="pokemon"
:disabled="busy"
:disabled="busy || !can('pokemon.order')"
:handle-label="dragSortLabel"
:handle-title="t('pages.admin.dragSortTitle')"
@preview="previewPokemonOrder"
@@ -923,7 +1308,7 @@ onMounted(() => {
<template #default="{ item }">
<RouterLink :to="`/pokemon/${item.id}`">#{{ item.displayId }} {{ item.name }}</RouterLink>
<span class="row-actions">
<button type="button" :disabled="busy" @click="removePokemon(item.id)">
<button v-if="can('pokemon.delete')" type="button" :disabled="busy" @click="removePokemon(item.id)">
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
{{ t('common.delete') }}
</button>
@@ -941,7 +1326,7 @@ onMounted(() => {
:item-key="itemKey"
:item-label="itemLabel"
list-key-prefix="items"
:disabled="busy"
:disabled="busy || !can('items.order')"
:handle-label="dragSortLabel"
:handle-title="t('pages.admin.dragSortTitle')"
@preview="previewItemOrder"
@@ -951,7 +1336,7 @@ onMounted(() => {
<template #default="{ item }">
<RouterLink :to="`/items/${item.id}`">{{ item.name }}</RouterLink>
<span class="row-actions">
<button type="button" :disabled="busy" @click="removeItem(item.id)">
<button v-if="can('items.delete')" type="button" :disabled="busy" @click="removeItem(item.id)">
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
{{ t('common.delete') }}
</button>
@@ -969,7 +1354,7 @@ onMounted(() => {
:item-key="recipeKey"
:item-label="recipeLabel"
list-key-prefix="recipes"
:disabled="busy"
:disabled="busy || !can('recipes.order')"
:handle-label="dragSortLabel"
:handle-title="t('pages.admin.dragSortTitle')"
@preview="previewRecipeOrder"
@@ -979,7 +1364,7 @@ onMounted(() => {
<template #default="{ item }">
<RouterLink :to="`/recipes/${item.id}`">{{ item.name }}</RouterLink>
<span class="row-actions">
<button type="button" :disabled="busy" @click="removeRecipe(item.id)">
<button v-if="can('recipes.delete')" type="button" :disabled="busy" @click="removeRecipe(item.id)">
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
{{ t('common.delete') }}
</button>
@@ -997,7 +1382,7 @@ onMounted(() => {
:item-key="habitatKey"
:item-label="habitatLabel"
list-key-prefix="habitats"
:disabled="busy"
:disabled="busy || !can('habitats.order')"
:handle-label="dragSortLabel"
:handle-title="t('pages.admin.dragSortTitle')"
@preview="previewHabitatOrder"
@@ -1007,7 +1392,7 @@ onMounted(() => {
<template #default="{ item }">
<RouterLink :to="`/habitats/${item.id}`">{{ item.name }}</RouterLink>
<span class="row-actions">
<button type="button" :disabled="busy" @click="removeHabitat(item.id)">
<button v-if="can('habitats.delete')" type="button" :disabled="busy" @click="removeHabitat(item.id)">
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
{{ t('common.delete') }}
</button>
@@ -1017,6 +1402,149 @@ onMounted(() => {
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
</section>
<Modal v-if="userRoleModalOpen" :title="userRoleModalTitle" :close-label="t('common.close')" size="wide" @close="closeUserRoleModal">
<form id="admin-user-roles-form" class="modal-edit-form" @submit.prevent="saveUserRoles">
<div v-if="editingUser" class="access-modal-heading">
<strong>{{ editingUser.displayName }}</strong>
<span class="meta-line">{{ editingUser.email }}</span>
</div>
<div class="permission-grid" role="group" :aria-label="t('pages.admin.roles')">
<label v-for="role in roleRows" :key="role.id" class="permission-toggle">
<input
type="checkbox"
:checked="userRoleForm.roleIds.includes(role.id)"
:disabled="busy || !role.enabled"
@change="toggleUserRole(role.id)"
/>
<span>
<strong>{{ role.name }}</strong>
<small>{{ role.description }}</small>
</span>
</label>
</div>
</form>
<template #footer>
<button type="submit" form="admin-user-roles-form" class="link-button" :disabled="busy">
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
{{ busy ? t('common.saving') : t('common.save') }}
</button>
<button type="button" class="plain-button" :disabled="busy" @click="closeUserRoleModal">
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
{{ t('common.cancel') }}
</button>
</template>
</Modal>
<Modal v-if="roleModalOpen" :title="roleModalTitle" :close-label="t('common.close')" @close="closeRoleModal">
<form id="admin-role-form" class="modal-edit-form" @submit.prevent="saveRole">
<div class="field">
<label for="role-key">{{ t('pages.admin.roleKey') }}</label>
<input id="role-key" v-model="roleForm.key" :disabled="Boolean(roleForm.id)" required />
</div>
<div class="field">
<label for="role-name">{{ t('pages.admin.roleName') }}</label>
<input id="role-name" v-model="roleForm.name" required />
</div>
<div class="field">
<label for="role-description">{{ t('pages.admin.description') }}</label>
<textarea id="role-description" v-model="roleForm.description"></textarea>
</div>
<div class="field">
<label for="role-level">{{ t('pages.admin.level') }}</label>
<input id="role-level" v-model.number="roleForm.level" type="number" min="0" step="1" required />
</div>
<div class="check-row">
<label><input v-model="roleForm.enabled" type="checkbox" /> {{ t('pages.admin.enabled') }}</label>
</div>
</form>
<template #footer>
<button type="submit" form="admin-role-form" class="link-button" :disabled="busy">
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
{{ busy ? t('common.saving') : t('common.save') }}
</button>
<button type="button" class="plain-button" :disabled="busy" @click="closeRoleModal">
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
{{ t('common.cancel') }}
</button>
</template>
</Modal>
<Modal v-if="rolePermissionsModalOpen" :title="rolePermissionsModalTitle" :close-label="t('common.close')" size="wide" @close="closeRolePermissionsModal">
<form id="admin-role-permissions-form" class="modal-edit-form" @submit.prevent="saveRolePermissions">
<div v-if="editingRole" class="access-modal-heading">
<strong>{{ editingRole.name }}</strong>
<span class="meta-line">{{ editingRole.description }}</span>
</div>
<div class="permission-groups">
<section v-for="group in permissionGroups" :key="group.category" class="permission-group">
<h3>{{ group.category }}</h3>
<div class="permission-grid" role="group" :aria-label="group.category">
<label v-for="permission in group.permissions" :key="permission.id" class="permission-toggle">
<input
type="checkbox"
:checked="rolePermissionForm.permissionIds.includes(permission.id)"
:disabled="busy || !permission.enabled"
@change="toggleRolePermission(permission.id)"
/>
<span>
<strong>{{ permission.name }}</strong>
<small>{{ permission.key }}</small>
</span>
</label>
</div>
</section>
</div>
</form>
<template #footer>
<button type="submit" form="admin-role-permissions-form" class="link-button" :disabled="busy">
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
{{ busy ? t('common.saving') : t('common.save') }}
</button>
<button type="button" class="plain-button" :disabled="busy" @click="closeRolePermissionsModal">
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
{{ t('common.cancel') }}
</button>
</template>
</Modal>
<Modal v-if="permissionModalOpen" :title="permissionModalTitle" :close-label="t('common.close')" @close="closePermissionModal">
<form id="admin-permission-form" class="modal-edit-form" @submit.prevent="savePermission">
<div class="field">
<label for="permission-key">{{ t('pages.admin.permissionKey') }}</label>
<input id="permission-key" v-model="permissionForm.key" :disabled="Boolean(permissionForm.id)" required />
</div>
<div class="field">
<label for="permission-name">{{ t('pages.admin.permissionName') }}</label>
<input id="permission-name" v-model="permissionForm.name" required />
</div>
<div class="field">
<label for="permission-category">{{ t('pages.admin.category') }}</label>
<input id="permission-category" v-model="permissionForm.category" required />
</div>
<div class="field">
<label for="permission-description">{{ t('pages.admin.description') }}</label>
<textarea id="permission-description" v-model="permissionForm.description"></textarea>
</div>
<div class="check-row">
<label><input v-model="permissionForm.enabled" type="checkbox" /> {{ t('pages.admin.enabled') }}</label>
</div>
</form>
<template #footer>
<button type="submit" form="admin-permission-form" class="link-button" :disabled="busy">
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
{{ busy ? t('common.saving') : t('common.save') }}
</button>
<button type="button" class="plain-button" :disabled="busy" @click="closePermissionModal">
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
{{ t('common.cancel') }}
</button>
</template>
</Modal>
<Modal v-if="checklistModalOpen" :title="checklistModalTitle" :close-label="t('common.close')" size="wide" @close="closeChecklistModal">
<form id="admin-checklist-form" class="modal-edit-form" @submit.prevent="saveChecklistItem">
<TranslationFields

View File

@@ -12,16 +12,18 @@ import PokeBallMark from '../components/PokeBallMark.vue';
import Skeleton from '../components/Skeleton.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue';
import { iconBack, iconEdit, iconHabitat } from '../icons';
import { api, type HabitatDetail } from '../services/api';
import { api, getAuthToken, type AuthUser, type HabitatDetail } from '../services/api';
import HabitatEdit from './HabitatEdit.vue';
const route = useRoute();
const { t } = useI18n();
const habitat = ref<HabitatDetail | null>(null);
const currentUser = ref<AuthUser | null>(null);
const detailTab = ref('details');
const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
const weathers = ['晴天', '阴天', '雨天'];
const showEditor = computed(() => route.name === 'habitat-edit');
const canUpdateHabitat = computed(() => currentUser.value?.permissions.includes('habitats.update') === true);
const detailTabs = computed<TabOption[]>(() => [
{ value: 'details', label: t('common.details') },
{ value: 'discussion', label: t('discussion.title') },
@@ -118,6 +120,13 @@ async function loadHabitatDetail() {
}
onMounted(async () => {
if (getAuthToken()) {
try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
}
await loadHabitatDetail();
});
@@ -190,7 +199,7 @@ watch(
<PageHeader :title="habitat.name" :subtitle="t('pages.habitats.detailSubtitle')">
<template #kicker>{{ t('pages.habitats.detailKicker') }}</template>
<template #actions>
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/habitats/${habitat.id}/edit`">
<RouterLink v-if="canUpdateHabitat" class="ui-button ui-button--primary ui-button--small" :to="`/habitats/${habitat.id}/edit`">
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
{{ t('common.edit') }}
</RouterLink>

View File

@@ -13,6 +13,8 @@ import TranslationFields from '../components/TranslationFields.vue';
import { iconAdd, iconCancel, iconDelete, iconPokemon, iconSave } from '../icons';
import {
api,
getAuthToken,
type AuthUser,
type ConfigType,
type EntityImage,
type EntityImageUpload,
@@ -40,6 +42,7 @@ const options = ref<Options | null>(null);
const itemRows = ref<Item[]>([]);
const pokemonRows = ref<Pokemon[]>([]);
const languages = ref<Language[]>([]);
const currentUser = ref<AuthUser | null>(null);
const currentImage = ref<EntityImage | null>(null);
const imageHistory = ref<EntityImageUpload[]>([]);
const loading = ref(true);
@@ -81,6 +84,8 @@ const pageTitle = computed(() =>
);
const cancelTo = computed(() => (isEditing.value ? `/habitats/${routeId.value}` : '/habitats'));
const imageEntityName = computed(() => habitatNameForSave().trim());
const canCreateConfig = computed(() => currentUser.value?.permissions.includes('admin.config.create') === true);
const canUploadImage = computed(() => currentUser.value?.permissions.includes('habitats.upload') === true);
function toIds(values: string[]): number[] {
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
@@ -146,12 +151,26 @@ function habitatNameForSave() {
return habitatForm.value.translations[String(locale.value || '')]?.name ?? '';
}
async function loadCurrentUser() {
if (!getAuthToken()) {
currentUser.value = null;
return;
}
try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
}
async function loadEditor() {
loading.value = true;
message.value = '';
try {
const [loadedOptions, loadedItems, loadedPokemon, loadedLanguages] = await Promise.all([
const [, loadedOptions, loadedItems, loadedPokemon, loadedLanguages] = await Promise.all([
loadCurrentUser(),
api.options(),
api.items({}),
api.pokemon({}),
@@ -188,7 +207,7 @@ async function loadOptions() {
async function createMultiOption(selectKey: string, type: ConfigType, name: string, values: string[]) {
const cleanName = name.trim();
if (!cleanName) return;
if (!cleanName || !canCreateConfig.value) return;
creatingSelect.value = selectKey;
message.value = '';
@@ -274,6 +293,7 @@ onMounted(() => {
:current-image="currentImage"
:history="imageHistory"
:disabled="busy"
:allow-upload="canUploadImage"
@selected="handleImageSelected"
@uploaded="handleImageUploaded"
@error="message = $event"
@@ -341,7 +361,7 @@ onMounted(() => {
:id="`appearance-maps-${index}`"
v-model="row.mapIds"
:options="options.maps"
allow-create
:allow-create="canCreateConfig"
:creating="creatingSelect === `appearance-maps-${index}`"
:placeholder="t('pages.habitats.searchMaps')"
@create="createMultiOption(`appearance-maps-${index}`, 'maps', $event, row.mapIds)"

View File

@@ -7,21 +7,30 @@ import EntityCard from '../components/EntityCard.vue';
import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue';
import { iconAdd, iconHabitat } from '../icons';
import { api, type Habitat } from '../services/api';
import { api, getAuthToken, type AuthUser, type Habitat } from '../services/api';
import HabitatEdit from './HabitatEdit.vue';
const habitats = ref<Habitat[]>([]);
const currentUser = ref<AuthUser | null>(null);
const route = useRoute();
const { t } = useI18n();
const loading = ref(true);
const skeletonCardCount = 6;
const showEditor = computed(() => route.name === 'habitat-new');
const canCreateHabitat = computed(() => currentUser.value?.permissions.includes('habitats.create') === true);
function habitatCardImage(item: Habitat) {
return item.image ? { src: item.image.url, alt: t('media.imageAlt', { name: item.name }) } : undefined;
}
onMounted(async () => {
if (getAuthToken()) {
try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
}
habitats.value = await api.habitats();
loading.value = false;
});
@@ -32,7 +41,7 @@ onMounted(async () => {
<PageHeader :title="t('pages.habitats.title')" :subtitle="t('pages.habitats.subtitle')">
<template #kicker>Habitats</template>
<template #actions>
<RouterLink class="ui-button ui-button--primary ui-button--small" to="/habitats/new">
<RouterLink v-if="canCreateHabitat" class="ui-button ui-button--primary ui-button--small" to="/habitats/new">
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
{{ t('common.add') }}
</RouterLink>

View File

@@ -12,14 +12,17 @@ import PokeBallMark from '../components/PokeBallMark.vue';
import Skeleton from '../components/Skeleton.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue';
import { iconAdd, iconBack, iconEdit, iconHabitat, iconItem } from '../icons';
import { api, type ItemDetail } from '../services/api';
import { api, getAuthToken, type AuthUser, type ItemDetail } from '../services/api';
import ItemEdit from './ItemEdit.vue';
const route = useRoute();
const { t } = useI18n();
const item = ref<ItemDetail | null>(null);
const currentUser = ref<AuthUser | null>(null);
const detailTab = ref('details');
const showEditor = computed(() => route.name === 'item-edit');
const canUpdateItem = computed(() => currentUser.value?.permissions.includes('items.update') === true);
const canCreateRecipe = computed(() => currentUser.value?.permissions.includes('recipes.create') === true);
const detailTabs = computed<TabOption[]>(() => [
{ value: 'details', label: t('common.details') },
{ value: 'discussion', label: t('discussion.title') },
@@ -50,6 +53,13 @@ async function loadItemDetail() {
}
onMounted(async () => {
if (getAuthToken()) {
try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
}
await loadItemDetail();
});
@@ -129,7 +139,7 @@ watch(
<PageHeader :title="item.name" :subtitle="itemSubtitle">
<template #kicker>{{ t('pages.items.detailKicker') }}</template>
<template #actions>
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/items/${item.id}/edit`">
<RouterLink v-if="canUpdateItem" class="ui-button ui-button--primary ui-button--small" :to="`/items/${item.id}/edit`">
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
{{ t('common.edit') }}
</RouterLink>
@@ -211,7 +221,7 @@ watch(
<p v-else-if="item.noRecipe" class="meta-line">{{ t('pages.items.noRecipe') }}</p>
<template v-else>
<p class="meta-line">{{ t('common.none') }}</p>
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/recipes/new?itemId=${item.id}`">
<RouterLink v-if="canCreateRecipe" class="ui-button ui-button--primary ui-button--small" :to="`/recipes/new?itemId=${item.id}`">
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
{{ t('pages.items.createRecipe') }}
</RouterLink>

View File

@@ -10,13 +10,25 @@ import StatusMessage from '../components/StatusMessage.vue';
import TagsSelect from '../components/TagsSelect.vue';
import TranslationFields from '../components/TranslationFields.vue';
import { iconCancel, iconSave } from '../icons';
import { api, type ConfigType, type EntityImage, type EntityImageUpload, type ItemPayload, type Language, type Options, type TranslationMap } from '../services/api';
import {
api,
getAuthToken,
type AuthUser,
type ConfigType,
type EntityImage,
type EntityImageUpload,
type ItemPayload,
type Language,
type Options,
type TranslationMap
} from '../services/api';
const route = useRoute();
const router = useRouter();
const { locale, t } = useI18n();
const options = ref<Options | null>(null);
const languages = ref<Language[]>([]);
const currentUser = ref<AuthUser | null>(null);
const currentImage = ref<EntityImage | null>(null);
const imageHistory = ref<EntityImageUpload[]>([]);
const loading = ref(true);
@@ -48,6 +60,8 @@ const pageTitle = computed(() =>
const cancelTo = computed(() => (isEditing.value ? `/items/${routeId.value}` : '/items'));
const hasRecipe = ref(false);
const imageEntityName = computed(() => itemNameForSave().trim());
const canCreateConfig = computed(() => currentUser.value?.permissions.includes('admin.config.create') === true);
const canUploadImage = computed(() => currentUser.value?.permissions.includes('items.upload') === true);
function toIds(values: string[]): number[] {
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
@@ -76,12 +90,25 @@ async function loadOptions() {
languages.value = loadedLanguages;
}
async function loadCurrentUser() {
if (!getAuthToken()) {
currentUser.value = null;
return;
}
try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
}
async function loadEditor() {
loading.value = true;
message.value = '';
try {
await loadOptions();
await Promise.all([loadCurrentUser(), loadOptions()]);
if (isEditing.value) {
const item = await api.itemDetail(routeId.value);
itemForm.value = {
@@ -111,7 +138,7 @@ async function loadEditor() {
async function createSingleOption(selectKey: string, type: ConfigType, name: string, assign: (value: string) => void) {
const cleanName = name.trim();
if (!cleanName) return;
if (!cleanName || !canCreateConfig.value) return;
creatingSelect.value = selectKey;
message.value = '';
@@ -128,7 +155,7 @@ async function createSingleOption(selectKey: string, type: ConfigType, name: str
async function createMultiOption(selectKey: string, type: ConfigType, name: string, values: string[]) {
const cleanName = name.trim();
if (!cleanName) return;
if (!cleanName || !canCreateConfig.value) return;
creatingSelect.value = selectKey;
message.value = '';
@@ -212,6 +239,7 @@ onMounted(() => {
:current-image="currentImage"
:history="imageHistory"
:disabled="busy"
:allow-upload="canUploadImage"
@selected="handleImageSelected"
@uploaded="handleImageUploaded"
@error="message = $event"
@@ -224,7 +252,7 @@ onMounted(() => {
v-model="itemForm.categoryId"
:options="options.itemCategories"
:multiple="false"
allow-create
:allow-create="canCreateConfig"
:creating="creatingSelect === 'item-category'"
:placeholder="t('common.select')"
:search-placeholder="t('pages.items.searchCategory')"
@@ -239,7 +267,7 @@ onMounted(() => {
v-model="itemForm.usageId"
:options="options.itemUsages"
:multiple="false"
allow-create
:allow-create="canCreateConfig"
:creating="creatingSelect === 'item-usage'"
:placeholder="t('common.none')"
:search-placeholder="t('pages.items.searchUsage')"
@@ -261,7 +289,7 @@ onMounted(() => {
id="item-methods"
v-model="itemForm.acquisitionMethodIds"
:options="options.acquisitionMethods"
allow-create
:allow-create="canCreateConfig"
:creating="creatingSelect === 'item-methods'"
:placeholder="t('pages.items.searchMethods')"
@create="createMultiOption('item-methods', 'acquisition-methods', $event, itemForm.acquisitionMethodIds)"
@@ -274,7 +302,7 @@ onMounted(() => {
id="item-tags"
v-model="itemForm.tagIds"
:options="options.itemTags"
allow-create
:allow-create="canCreateConfig"
:creating="creatingSelect === 'item-tags'"
:placeholder="t('pages.items.searchTags')"
@create="createMultiOption('item-tags', 'favorite-things', $event, itemForm.tagIds)"

View File

@@ -10,13 +10,14 @@ import Skeleton from '../components/Skeleton.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue';
import TagsSelect from '../components/TagsSelect.vue';
import { iconAdd, iconItem } from '../icons';
import { api, type Item, type Options } from '../services/api';
import { api, getAuthToken, type AuthUser, type Item, type Options } from '../services/api';
import ItemEdit from './ItemEdit.vue';
const options = ref<Options | null>(null);
const route = useRoute();
const { t } = useI18n();
const items = ref<Item[]>([]);
const currentUser = ref<AuthUser | null>(null);
const loading = ref(true);
const search = ref('');
const categoryId = ref('');
@@ -39,6 +40,7 @@ const itemQuery = computed(() => ({
tagIds: tagIds.value.join(',')
}));
const showEditor = computed(() => route.name === 'item-new');
const canCreateItem = computed(() => currentUser.value?.permissions.includes('items.create') === true);
function itemCardImage(item: Item) {
return item.image ? { src: item.image.url, alt: t('media.imageAlt', { name: item.name }) } : undefined;
@@ -51,6 +53,13 @@ async function loadItems() {
}
onMounted(async () => {
if (getAuthToken()) {
try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
}
options.value = await api.options();
await loadItems();
});
@@ -63,7 +72,7 @@ watch(itemQuery, loadItems);
<PageHeader :title="t('pages.items.title')" :subtitle="t('pages.items.subtitle')">
<template #kicker>Bag</template>
<template #actions>
<RouterLink class="ui-button ui-button--primary ui-button--small" to="/items/new">
<RouterLink v-if="canCreateItem" class="ui-button ui-button--primary ui-button--small" to="/items/new">
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
{{ t('common.add') }}
</RouterLink>

View File

@@ -86,7 +86,13 @@ const reactionOptions = [
{ type: 'thanks', icon: iconReactionThanks, labelKey: 'pages.life.reactionThanks' }
] as const satisfies ReadonlyArray<{ type: LifeReactionType; icon: string; labelKey: string }>;
const canPost = computed(() => currentUser.value?.emailVerified === true);
function can(permissionKey: string) {
return currentUser.value?.permissions.includes(permissionKey) === true;
}
const canPost = computed(() => can('life.posts.create'));
const canComment = computed(() => can('life.comments.create'));
const canReact = computed(() => can('life.reactions.set'));
const charactersLeft = computed(() => Math.max(0, bodyMaxLength - body.value.length));
const isEditing = computed(() => editingPostId.value !== null);
const searchQuery = computed(() => submittedSearch.value.trim());
@@ -303,11 +309,15 @@ async function submitPost() {
}
function canManage(post: LifePost) {
return currentUser.value?.id === post.author?.id;
return (currentUser.value?.id === post.author?.id && can('life.posts.update')) || can('life.posts.update-any');
}
function canDeletePost(post: LifePost) {
return (currentUser.value?.id === post.author?.id && can('life.posts.delete')) || can('life.posts.delete-any');
}
function canManageComment(comment: LifeComment) {
return !comment.deleted && currentUser.value?.id === comment.author?.id;
return !comment.deleted && ((currentUser.value?.id === comment.author?.id && can('life.comments.delete')) || can('life.comments.delete-any'));
}
function commentKey(postId: number) {
@@ -411,7 +421,7 @@ function clearReactionError(postId: number) {
}
function canUseReactions() {
return canPost.value && reactionBusyPostId.value === null;
return canReact.value && reactionBusyPostId.value === null;
}
function closeReactionPicker() {
@@ -808,12 +818,13 @@ onUnmounted(() => {
</span>
</div>
<div v-if="canManage(post)" class="life-post__actions">
<button class="life-icon-button" type="button" :aria-label="t('pages.life.editPost')" @click="startEdit(post)">
<div v-if="canManage(post) || canDeletePost(post)" class="life-post__actions">
<button v-if="canManage(post)" class="life-icon-button" type="button" :aria-label="t('pages.life.editPost')" @click="startEdit(post)">
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.editPost') }}</span>
</button>
<button
v-if="canDeletePost(post)"
class="life-icon-button life-icon-button--danger"
type="button"
:aria-label="t('pages.life.deletePost')"
@@ -842,7 +853,7 @@ onUnmounted(() => {
:aria-controls="`life-reactions-${post.id}`"
:aria-expanded="reactionPickerPostId === post.id"
:aria-label="reactionButtonLabel(post)"
:disabled="!canPost || reactionBusyPostId !== null"
:disabled="!canReact || reactionBusyPostId !== null"
@click="toggleDefaultReaction(post)"
@contextmenu="handleReactionContextMenu($event, post.id)"
@keydown="handleReactionKeydown($event, post.id)"
@@ -857,7 +868,7 @@ onUnmounted(() => {
:aria-controls="`life-reactions-${post.id}`"
:aria-expanded="reactionPickerPostId === post.id"
:aria-label="t('pages.life.chooseReaction')"
:disabled="!canPost || reactionBusyPostId !== null"
:disabled="!canReact || reactionBusyPostId !== null"
@click="toggleReactionPicker(post.id)"
@contextmenu="handleReactionContextMenu($event, post.id)"
@keydown="handleReactionKeydown($event, post.id)"
@@ -868,7 +879,7 @@ onUnmounted(() => {
</div>
<div
v-if="reactionPickerPostId === post.id && canPost"
v-if="reactionPickerPostId === post.id && canReact"
:id="`life-reactions-${post.id}`"
class="life-reaction-picker"
role="group"
@@ -953,7 +964,7 @@ onUnmounted(() => {
<span>{{ commentCount(post) }}</span>
</div>
<form v-if="canPost" class="life-comment-form" @submit.prevent="submitComment(post)">
<form v-if="canComment" class="life-comment-form" @submit.prevent="submitComment(post)">
<div class="field">
<label :for="`life-comment-${post.id}`">{{ t('pages.life.comment') }}</label>
<textarea
@@ -994,7 +1005,7 @@ onUnmounted(() => {
<div v-if="!comment.deleted" class="life-comment__actions">
<button
v-if="canPost"
v-if="canComment"
class="life-icon-button life-icon-button--flat"
type="button"
:aria-label="t('pages.life.reply')"
@@ -1020,7 +1031,7 @@ onUnmounted(() => {
</p>
<form
v-if="canPost && replyTargetId === comment.id"
v-if="canComment && replyTargetId === comment.id"
class="life-comment-form life-comment-form--reply"
@submit.prevent="submitReply(post, comment)"
>

View File

@@ -14,12 +14,13 @@ import PokemonStatsPanel from '../components/PokemonStatsPanel.vue';
import Skeleton from '../components/Skeleton.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue';
import { iconBack, iconEdit, iconHabitat, iconItem } from '../icons';
import { api, type PokemonDetail } from '../services/api';
import { api, getAuthToken, type AuthUser, type PokemonDetail } from '../services/api';
import PokemonEdit from './PokemonEdit.vue';
const route = useRoute();
const { t } = useI18n();
const pokemon = ref<PokemonDetail | null>(null);
const currentUser = ref<AuthUser | null>(null);
const itemCategoryTab = ref('');
const relatedHabitatTab = ref('');
const detailTab = ref('details');
@@ -118,6 +119,7 @@ const habitatRows = computed<HabitatRow[]>(() => {
});
const skillDropRows = computed(() => pokemon.value?.skills.filter((skill) => skill.itemDrop) ?? []);
const showEditor = computed(() => route.name === 'pokemon-edit');
const canUpdatePokemon = computed(() => currentUser.value?.permissions.includes('pokemon.update') === true);
const detailTabs = computed<TabOption[]>(() => [
{ value: 'details', label: t('common.details') },
{ value: 'discussion', label: t('discussion.title') },
@@ -222,6 +224,13 @@ async function loadPokemonDetail() {
}
onMounted(async () => {
if (getAuthToken()) {
try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
}
await loadPokemonDetail();
});
@@ -307,7 +316,7 @@ watch(
<PageHeader :title="`#${pokemon.displayId} ${pokemon.name}`" :subtitle="t('pages.pokemon.environmentPrefix', { name: pokemon.environment.name })">
<template #kicker>Pokédex Detail</template>
<template #actions>
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/pokemon/${pokemon.id}/edit`">
<RouterLink v-if="canUpdatePokemon" class="ui-button ui-button--primary ui-button--small" :to="`/pokemon/${pokemon.id}/edit`">
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
{{ t('common.edit') }}
</RouterLink>

View File

@@ -14,6 +14,8 @@ import TranslationFields from '../components/TranslationFields.vue';
import { iconCancel, iconSave, iconSearch } from '../icons';
import {
api,
getAuthToken,
type AuthUser,
type ConfigType,
type EntityImage,
type EntityImageUpload,
@@ -39,6 +41,7 @@ const { locale, t } = useI18n();
const options = ref<Options | null>(null);
const itemOptions = ref<NamedEntity[]>([]);
const languages = ref<Language[]>([]);
const currentUser = ref<AuthUser | null>(null);
const loading = ref(true);
const busy = ref(false);
const fetchBusy = ref(false);
@@ -125,6 +128,9 @@ const displayedImageOptions = computed(() => {
return [selectedImage, ...imageOptions.value];
});
const selectedUploadImage = computed(() => (selectedPokemonImage.value?.source === 'upload' ? selectedPokemonImage.value : null));
const canCreateConfig = computed(() => currentUser.value?.permissions.includes('admin.config.create') === true);
const canFetchPokemon = computed(() => currentUser.value?.permissions.includes('pokemon.fetch') === true);
const canUploadImage = computed(() => currentUser.value?.permissions.includes('pokemon.upload') === true);
function toIds(values: string[]): number[] {
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
@@ -171,6 +177,19 @@ async function loadOptions() {
languages.value = loadedLanguages;
}
async function loadCurrentUser() {
if (!getAuthToken()) {
currentUser.value = null;
return;
}
try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
}
function syncSkillItemDrops() {
const selectedSkillIds = new Set(pokemonForm.value.skillIds);
const rows = pokemonForm.value.skillItemDrops.filter((row) => selectedSkillIds.has(row.skillId) && skillSupportsItemDrop(row.skillId));
@@ -270,7 +289,7 @@ async function loadEditor() {
message.value = '';
try {
await loadOptions();
await Promise.all([loadCurrentUser(), loadOptions()]);
if (isEditing.value) {
const pokemon = await api.pokemonDetail(routeId.value);
pokemonForm.value = {
@@ -316,6 +335,10 @@ function fetchOptionLabel(option: PokemonFetchOption) {
}
async function loadFetchOptions() {
if (!canFetchPokemon.value) {
return;
}
cancelFetchOptionsRequest();
const controller = new AbortController();
fetchOptionsController = controller;
@@ -351,6 +374,10 @@ function refreshFetchOptions() {
}
function openFetchOptions() {
if (!canFetchPokemon.value) {
return;
}
fetchOptionsOpen.value = true;
refreshFetchOptions();
}
@@ -361,6 +388,10 @@ function closeFetchOptions() {
}
function handleFetchIdentifierInput() {
if (!canFetchPokemon.value) {
return;
}
fetchOptionsOpen.value = true;
}
@@ -375,6 +406,10 @@ async function selectFetchOption(option: PokemonFetchOption) {
}
async function fetchPokemonByIdentifier(identifierValue?: string) {
if (!canFetchPokemon.value) {
return;
}
const identifier = (identifierValue ?? fetchIdentifier.value).trim() || pokemonForm.value.id.trim();
if (!identifier) {
message.value = t('pages.pokemon.fetchIdentifierRequired');
@@ -446,6 +481,10 @@ function handleUploadImageUploaded(image: EntityImageUpload) {
}
async function fetchPokemonImages() {
if (!canFetchPokemon.value) {
return;
}
const identifier = fetchIdentifier.value.trim() || pokemonForm.value.id.trim();
if (!identifier) {
message.value = t('pages.pokemon.fetchIdentifierRequired');
@@ -487,7 +526,7 @@ function fetchPokemonImagesFromInput() {
async function createSingleOption(selectKey: string, type: ConfigType, name: string, assign: (value: string) => void) {
const cleanName = name.trim();
if (!cleanName) return;
if (!cleanName || !canCreateConfig.value) return;
creatingSelect.value = selectKey;
message.value = '';
@@ -504,7 +543,7 @@ async function createSingleOption(selectKey: string, type: ConfigType, name: str
async function createMultiOption(selectKey: string, type: ConfigType, name: string, values: string[], max = 0) {
const cleanName = name.trim();
if (!cleanName || (max > 0 && values.length >= max)) return;
if (!cleanName || !canCreateConfig.value || (max > 0 && values.length >= max)) return;
creatingSelect.value = selectKey;
message.value = '';
@@ -581,7 +620,7 @@ watch(fetchIdentifier, refreshFetchOptions);
<form v-if="!loading && options" id="pokemon-edit-form" class="modal-edit-form modal-edit-form--tabbed pokemon-edit-form" @submit.prevent="savePokemon">
<Tabs id="pokemon-edit-tabs" v-model="activeEditTab" :tabs="editTabs" :label="t('pages.pokemon.editSections')" />
<div class="pokemon-fetch-panel" :aria-label="t('pages.pokemon.fetchData')">
<div v-if="canFetchPokemon" class="pokemon-fetch-panel" :aria-label="t('pages.pokemon.fetchData')">
<div class="field pokemon-fetch-panel__input">
<label for="pokemon-fetch-identifier">{{ t('pages.pokemon.fetchIdentifier') }}</label>
<input
@@ -660,7 +699,7 @@ watch(fetchIdentifier, refreshFetchOptions);
v-model="pokemonForm.environmentId"
:options="options.environments"
:multiple="false"
allow-create
:allow-create="canCreateConfig"
:creating="creatingSelect === 'pokemon-environment'"
:placeholder="t('common.select')"
:search-placeholder="t('pages.pokemon.searchEnvironment')"
@@ -675,7 +714,7 @@ watch(fetchIdentifier, refreshFetchOptions);
v-model="pokemonForm.skillIds"
:options="options.skills"
:max="2"
allow-create
:allow-create="canCreateConfig"
:creating="creatingSelect === 'pokemon-skills'"
:placeholder="t('pages.pokemon.searchSkills')"
@create="createMultiOption('pokemon-skills', 'skills', $event, pokemonForm.skillIds, 2)"
@@ -690,7 +729,7 @@ watch(fetchIdentifier, refreshFetchOptions);
v-model="pokemonForm.favoriteThingIds"
:options="options.favoriteThings"
:max="6"
allow-create
:allow-create="canCreateConfig"
:creating="creatingSelect === 'pokemon-things'"
:placeholder="t('pages.pokemon.searchFavoriteThings')"
@create="createMultiOption('pokemon-things', 'favorite-things', $event, pokemonForm.favoriteThingIds, 6)"
@@ -764,6 +803,7 @@ watch(fetchIdentifier, refreshFetchOptions);
:current-image="selectedUploadImage"
:history="imageHistory"
:disabled="busy || imageBusy"
:allow-upload="canUploadImage"
:show-preview="false"
@selected="handleUploadImageSelected"
@uploaded="handleUploadImageUploaded"

View File

@@ -9,13 +9,14 @@ import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue';
import TagsSelect from '../components/TagsSelect.vue';
import { iconAdd } from '../icons';
import { api, type Options, type Pokemon } from '../services/api';
import { api, getAuthToken, type AuthUser, type Options, type Pokemon } from '../services/api';
import PokemonEdit from './PokemonEdit.vue';
const options = ref<Options | null>(null);
const route = useRoute();
const { t } = useI18n();
const pokemon = ref<Pokemon[]>([]);
const currentUser = ref<AuthUser | null>(null);
const loading = ref(true);
const search = ref('');
const environmentId = ref('');
@@ -35,6 +36,7 @@ const query = computed(() => ({
favoriteThingMode: favoriteThingMode.value
}));
const showEditor = computed(() => route.name === 'pokemon-new');
const canCreatePokemon = computed(() => currentUser.value?.permissions.includes('pokemon.create') === true);
async function loadPokemon() {
loading.value = true;
@@ -47,6 +49,13 @@ function pokemonCardImage(item: Pokemon) {
}
onMounted(async () => {
if (getAuthToken()) {
try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
}
options.value = await api.options();
await loadPokemon();
});
@@ -59,7 +68,7 @@ watch(query, loadPokemon);
<PageHeader :title="t('pages.pokemon.title')" :subtitle="t('pages.pokemon.subtitle')">
<template #kicker>Pokédex</template>
<template #actions>
<RouterLink class="ui-button ui-button--primary ui-button--small" to="/pokemon/new">
<RouterLink v-if="canCreatePokemon" class="ui-button ui-button--primary ui-button--small" to="/pokemon/new">
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
{{ t('common.add') }}
</RouterLink>

View File

@@ -11,14 +11,16 @@ import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue';
import { iconBack, iconEdit, iconRecipe } from '../icons';
import { api, type RecipeDetail } from '../services/api';
import { api, getAuthToken, type AuthUser, type RecipeDetail } from '../services/api';
import RecipeEdit from './RecipeEdit.vue';
const route = useRoute();
const { t } = useI18n();
const recipe = ref<RecipeDetail | null>(null);
const currentUser = ref<AuthUser | null>(null);
const detailTab = ref('details');
const showEditor = computed(() => route.name === 'recipe-edit');
const canUpdateRecipe = computed(() => currentUser.value?.permissions.includes('recipes.update') === true);
const detailTabs = computed<TabOption[]>(() => [
{ value: 'details', label: t('common.details') },
{ value: 'discussion', label: t('discussion.title') },
@@ -44,6 +46,13 @@ async function loadRecipeDetail() {
}
onMounted(async () => {
if (getAuthToken()) {
try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
}
await loadRecipeDetail();
});
@@ -97,7 +106,7 @@ watch(
<PageHeader :title="recipe.name" :subtitle="recipeSubtitle">
<template #kicker>{{ t('pages.recipes.detailKicker') }}</template>
<template #actions>
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/recipes/${recipe.id}/edit`">
<RouterLink v-if="canUpdateRecipe" class="ui-button ui-button--primary ui-button--small" :to="`/recipes/${recipe.id}/edit`">
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
{{ t('common.edit') }}
</RouterLink>

View File

@@ -8,13 +8,14 @@ import Skeleton from '../components/Skeleton.vue';
import StatusMessage from '../components/StatusMessage.vue';
import TagsSelect from '../components/TagsSelect.vue';
import { iconAdd, iconCancel, iconDelete, iconSave } from '../icons';
import { api, type ConfigType, type Item, type Options, type RecipePayload } from '../services/api';
import { api, getAuthToken, type AuthUser, type ConfigType, type Item, type Options, type RecipePayload } from '../services/api';
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const options = ref<Options | null>(null);
const itemRows = ref<Item[]>([]);
const currentUser = ref<AuthUser | null>(null);
const loading = ref(true);
const busy = ref(false);
const message = ref('');
@@ -40,6 +41,7 @@ const pageTitle = computed(() =>
: t('pages.recipes.newTitle')
);
const cancelTo = computed(() => (isEditing.value ? `/recipes/${routeId.value}` : '/recipes'));
const canCreateConfig = computed(() => currentUser.value?.permissions.includes('admin.config.create') === true);
function toIds(values: string[]): number[] {
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
@@ -73,7 +75,7 @@ async function loadEditor() {
message.value = '';
try {
const [loadedOptions, loadedItems] = await Promise.all([api.options(), api.items({})]);
const [, loadedOptions, loadedItems] = await Promise.all([loadCurrentUser(), api.options(), api.items({})]);
options.value = loadedOptions;
itemRows.value = loadedItems;
@@ -102,9 +104,22 @@ async function loadOptions() {
options.value = await api.options();
}
async function loadCurrentUser() {
if (!getAuthToken()) {
currentUser.value = null;
return;
}
try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
}
async function createMultiOption(selectKey: string, type: ConfigType, name: string, values: string[]) {
const cleanName = name.trim();
if (!cleanName) return;
if (!cleanName || !canCreateConfig.value) return;
creatingSelect.value = selectKey;
message.value = '';
@@ -169,7 +184,7 @@ onMounted(() => {
id="recipe-methods"
v-model="recipeForm.acquisitionMethodIds"
:options="options.acquisitionMethods"
allow-create
:allow-create="canCreateConfig"
:creating="creatingSelect === 'recipe-methods'"
:placeholder="t('pages.items.searchMethods')"
@create="createMultiOption('recipe-methods', 'acquisition-methods', $event, recipeForm.acquisitionMethodIds)"

View File

@@ -10,13 +10,14 @@ import Skeleton from '../components/Skeleton.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue';
import TagsSelect from '../components/TagsSelect.vue';
import { iconAdd, iconNoRecipe, iconRecipe } from '../icons';
import { api, type Item, type Options } from '../services/api';
import { api, getAuthToken, type AuthUser, type Item, type Options } from '../services/api';
import RecipeEdit from './RecipeEdit.vue';
const options = ref<Options | null>(null);
const route = useRoute();
const { t } = useI18n();
const items = ref<Item[]>([]);
const currentUser = ref<AuthUser | null>(null);
const loading = ref(true);
const search = ref('');
const categoryId = ref('');
@@ -40,6 +41,7 @@ const itemQuery = computed(() => ({
recipeOrder: 1
}));
const showEditor = computed(() => route.name === 'recipe-new');
const canCreateRecipe = computed(() => currentUser.value?.permissions.includes('recipes.create') === true);
function recipeTarget(item: Item) {
return item.recipe ? `/recipes/${item.recipe.id}` : undefined;
@@ -68,6 +70,13 @@ async function loadItems() {
}
onMounted(async () => {
if (getAuthToken()) {
try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
}
options.value = await api.options();
await loadItems();
});
@@ -80,7 +89,7 @@ watch(itemQuery, loadItems);
<PageHeader :title="t('pages.recipes.title')" :subtitle="t('pages.recipes.subtitle')">
<template #kicker>Recipes</template>
<template #actions>
<RouterLink class="ui-button ui-button--primary ui-button--small" to="/recipes/new">
<RouterLink v-if="canCreateRecipe" class="ui-button ui-button--primary ui-button--small" to="/recipes/new">
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
{{ t('common.add') }}
</RouterLink>
@@ -172,7 +181,7 @@ watch(itemQuery, loadItems);
{{ t('pages.items.createRecipe') }}
</button>
<RouterLink
v-else
v-else-if="canCreateRecipe"
class="ui-button ui-button--primary ui-button--small catalog-card-action"
:to="createRecipeTarget(item)"
>