Files
pokopiawiki.tootaio.com/frontend/app.vue
xiaomai fd1f3ef636 feat(auth): implement hybrid session model with HTTP-only cookies
Add HTTP-only cookie session support to backend for SSR compatibility
Update frontend fetch calls to include credentials
Maintain legacy bearer token support for SPA transition
2026-05-06 09:48:18 +08:00

189 lines
5.0 KiB
Vue

<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import AppShell from './src/components/AppShell.vue';
import {
iconAction,
iconAdmin,
iconArtifact,
iconAutomation,
iconChecklist,
iconClothes,
iconDish,
iconDreamIsland,
iconEvent,
iconHabitat,
iconHome,
iconItem,
iconLife,
iconPokemon,
iconRecipe,
type AppIcon
} from './src/icons';
import { getCurrentLocale, loadSystemWordings, onLocaleChange, setCurrentLocale } from './src/i18n';
import { api, getAuthToken, onAuthTokenChange, setAuthToken, type AuthUser, type Language } from './src/services/api';
const { t, locale } = useI18n();
const router = useRouter();
const currentUser = ref<AuthUser | null>(null);
const languages = ref<Language[]>([
{ code: 'en', name: 'English', enabled: true, isDefault: true, sortOrder: 10 },
{ code: 'zh-CN', name: '简体中文', enabled: true, isDefault: false, sortOrder: 20 }
]);
let removeAuthListener: (() => void) | null = null;
let removeLocaleListener: (() => void) | null = null;
type NavBadge = {
label: string;
tone?: 'info' | 'success' | 'warning' | 'danger' | 'neutral';
};
type NavLinkItem = {
label: string;
to: string;
icon?: AppIcon;
badge?: NavBadge;
};
type NavGroupItem = {
key: string;
label: string;
icon?: AppIcon;
children: NavLinkItem[];
};
type NavItem = NavLinkItem | NavGroupItem;
function inDevBadge(): NavBadge {
return { label: t('common.inDev'), tone: 'info' };
}
function can(permissionKey: string) {
return currentUser.value?.permissions.includes(permissionKey) === true;
}
const navItems = computed<NavItem[]>(() => {
const items: NavItem[] = [
{ label: t('nav.home'), to: '/', icon: iconHome },
{
key: 'pokedex',
label: t('nav.pokedex'),
icon: iconPokemon,
children: [
{ label: t('nav.mainGame'), to: '/pokemon', icon: iconPokemon },
{ label: t('nav.event'), to: '/event-pokemon', icon: iconEvent }
]
},
{
key: 'habitat-dex',
label: t('nav.habitatDex'),
icon: iconHabitat,
children: [
{ label: t('nav.mainGame'), to: '/habitats', icon: iconHabitat },
{ label: t('nav.event'), to: '/event-habitats', icon: iconEvent }
]
},
{
key: 'collections',
label: t('nav.collections'),
icon: iconItem,
children: [
{ label: t('nav.mainGame'), to: '/items', icon: iconItem },
{ label: t('nav.event'), to: '/event-items', icon: iconEvent },
{ label: t('nav.ancientArtifacts'), to: '/ancient-artifacts', icon: iconArtifact }
]
},
{ label: t('nav.recipes'), to: '/recipes', icon: iconRecipe },
{ label: t('nav.automation'), to: '/automation', icon: iconAutomation, badge: inDevBadge() },
{ label: t('nav.dish'), to: '/dish', icon: iconDish },
{ 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() {
try {
const response = await api.me();
currentUser.value = response.user;
} catch {
currentUser.value = null;
if (getAuthToken()) {
setAuthToken(null);
}
}
}
async function logout() {
try {
await api.logout();
} catch {
// The local session is cleared even when the server session is already gone.
}
currentUser.value = null;
setAuthToken(null);
await router.push('/');
}
async function loadLanguages() {
try {
const loadedLanguages = await api.languages();
if (loadedLanguages.length) {
languages.value = loadedLanguages;
}
if (!languages.value.some((language) => language.code === getCurrentLocale() && language.enabled)) {
setCurrentLocale('en');
}
await loadSystemWordings(getCurrentLocale());
} catch {
// Keep the built-in language list when the API is not ready yet.
}
}
async function updateLocale(value: string) {
await loadSystemWordings(value);
setCurrentLocale(value);
}
onMounted(() => {
void loadLanguages();
void loadCurrentUser();
removeAuthListener = onAuthTokenChange(() => {
void loadCurrentUser();
});
removeLocaleListener = onLocaleChange(() => {
void loadLanguages();
});
});
onUnmounted(() => {
removeAuthListener?.();
removeLocaleListener?.();
});
</script>
<template>
<AppShell
:current-user="currentUser"
:languages="languages"
:locale="locale"
:nav-items="navItems"
@logout="logout"
@update:locale="updateLocale"
>
<NuxtPage :key="locale" />
</AppShell>
</template>