fix: localize skin site UI and renew JWT on startup

Catalog missing skin-site messages and check source IDs during i18n validation. Renew the saved JWT before loading session data, guard overlapping login/logout requests, and reload announcements after authentication with a retry action.
This commit is contained in:
Disy committed 2026-10-20 20:28:13 +08:00
1 parent 2d7683b0bb
commit 7f3a1cdf5c
11 files changed
+4744 -2341

No files matched your search

@@ -70,6 +70,13 @@ const messages = defineMessages({
defaultMessage: 'Selected',
},
idLabel: { id: 'app.skin-site.chat.user-picker.id', defaultMessage: 'ID' },
emailLabel: { id: 'app.skin-site.chat.user-picker.email', defaultMessage: 'Email' },
registeredLabel: {
id: 'app.skin-site.chat.user-picker.registered',
defaultMessage: 'Registered',
},
bannedStatus: { id: 'app.skin-site.chat.user-picker.banned-status', defaultMessage: 'Banned' },
activeStatus: { id: 'app.skin-site.chat.user-picker.active-status', defaultMessage: 'Active' },
})
const keyword = ref('')
@@ -82,9 +89,7 @@ let timer: ReturnType<typeof setTimeout> | undefined
let requestId = 0
const currentLabel = computed(() => props.label ?? formatMessage(messages.label))
const currentPlaceholder = computed(
() => props.placeholder ?? formatMessage(messages.placeholder),
)
const currentPlaceholder = computed(() => props.placeholder ?? formatMessage(messages.placeholder))
watch([keyword, searchMode], () => {
clearTimeout(timer)
@@ -174,13 +179,19 @@ function formatTime(ts: number): string {
v-model.trim="keyword"
type="text"
maxlength="100"
:placeholder="searchMode === 'player' ? formatMessage(messages.playerHint) : currentPlaceholder"
:placeholder="
searchMode === 'player' ? formatMessage(messages.playerHint) : currentPlaceholder
"
class="min-w-0 flex-1 rounded-lg border border-solid border-surface-5 bg-bg-raised px-3 py-1.5 text-xs text-contrast outline-none focus:border-brand"
autocomplete="off"
/>
</div>
<span class="text-[11px] text-secondary">
{{ searchMode === 'player' ? formatMessage(messages.playerHint) : formatMessage(messages.hint) }}
{{
searchMode === 'player'
? formatMessage(messages.playerHint)
: formatMessage(messages.hint)
}}
</span>
</div>
@@ -201,7 +212,9 @@ function formatTime(ts: number): string {
@click="preview = user"
>
<span class="flex min-w-0 items-center gap-2">
<span class="grid size-8 shrink-0 place-items-center rounded-lg bg-brand/15 text-xs font-bold text-brand">
<span
class="grid size-8 shrink-0 place-items-center rounded-lg bg-brand/15 text-xs font-bold text-brand"
>
{{ user.username.slice(0, 1).toUpperCase() }}
</span>
<span class="min-w-0">
@@ -244,7 +257,7 @@ function formatTime(ts: number): string {
class="rounded-full px-2 py-0.5 text-[10px] font-semibold"
:class="preview.banned ? 'bg-red text-contrast' : 'bg-green text-contrast'"
>
{{ preview.banned ? 'Banned' : 'OK' }}
{{ formatMessage(preview.banned ? messages.bannedStatus : messages.activeStatus) }}
</span>
</div>
<div class="grid gap-2 text-[11px] sm:grid-cols-2">
@@ -253,7 +266,7 @@ function formatTime(ts: number): string {
<span class="block break-all font-mono text-contrast">{{ preview.uuid }}</span>
</div>
<div>
<span class="block text-secondary">Email</span>
<span class="block text-secondary">{{ formatMessage(messages.emailLabel) }}</span>
<span class="block break-all text-contrast">{{ preview.email }}</span>
</div>
<div>
@@ -261,7 +274,7 @@ function formatTime(ts: number): string {
<span class="block text-contrast">{{ preview.qq ?? '—' }}</span>
</div>
<div>
<span class="block text-secondary">Registered</span>
<span class="block text-secondary">{{ formatMessage(messages.registeredLabel) }}</span>
<span class="block text-contrast">{{ formatTime(preview.registerTime) }}</span>
</div>
</div>
@@ -283,7 +296,11 @@ function formatTime(ts: number): string {
:disabled="Boolean(unavailableReason(preview))"
@click="selectPreview"
>
{{ model?.uuid === preview.uuid ? formatMessage(messages.selected) : formatMessage(messages.select) }}
{{
model?.uuid === preview.uuid
? formatMessage(messages.selected)
: formatMessage(messages.select)
}}
</button>
</section>
</div>
@@ -0,0 +1,142 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { createRequire, registerHooks, stripTypeScriptTypes } from 'node:module'
import test from 'node:test'
import { createRenderer, nextTick, ref } from 'vue'
const testSession = {
skinSiteStatus: ref('checking'),
skinSiteUser: ref<{ uuid: string } | null>(null),
}
const testGlobal = globalThis as unknown as {
skinSiteAnnouncementSession: typeof testSession
skinSiteAnnouncementFetch: () => Promise<unknown>
}
testGlobal.skinSiteAnnouncementSession = testSession
const require = createRequire(import.meta.url)
const { parse, compileScript } = require('vue/compiler-sfc')
const componentUrl = new URL('./SkinSiteAnnouncements.vue', import.meta.url).href
const stubs: Record<string, string> = {
'@modrinth/assets': 'export const SpinnerIcon={}',
'@modrinth/ui':
'export const commonMessages={retryButton:{defaultMessage:"Retry"}}; export const defineMessages=x=>x; export const useVIntl=()=>({formatMessage:x=>x.defaultMessage})',
'@/helpers/skin-site-api':
'export const skinSiteAnnouncements=()=>globalThis.skinSiteAnnouncementFetch()',
'@/composables/skin-site-session':
'export const {skinSiteStatus,skinSiteUser}=globalThis.skinSiteAnnouncementSession; export const reloadSkinSiteSession=async()=>{}',
}
const hooks = registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier in stubs)
return {
url: `data:text/javascript,${encodeURIComponent(stubs[specifier])}`,
shortCircuit: true,
}
return nextResolve(specifier, context)
},
load(url, context, nextLoad) {
if (url !== componentUrl) return nextLoad(url, context)
const { descriptor } = parse(readFileSync(new URL(url), 'utf8'))
return {
format: 'module',
source: stripTypeScriptTypes(
compileScript(descriptor, { id: 'skin-announcement-test' }).content,
),
shortCircuit: true,
}
},
})
const { default: Announcements } = await import(componentUrl)
hooks.deregister()
Announcements.render = () => null
type Node = Record<string, unknown>
const renderer = createRenderer<Node, Node>({
createElement: () => ({}),
createText: () => ({}),
createComment: () => ({}),
insert() {},
remove() {},
setText() {},
setElementText() {},
patchProp() {},
parentNode: () => null,
nextSibling: () => null,
})
const flush = async () => {
await new Promise((resolve) => setImmediate(resolve))
await nextTick()
}
type State = {
announcements: Array<{ id: number }>
loading: boolean
failed: boolean
load: () => Promise<void>
}
let requests = 0
function mount(t: test.TestContext) {
const app = renderer.createApp(Announcements)
const instance = app.mount({})
t.after(() => app.unmount())
return instance.$.setupState as State
}
test.beforeEach(() => {
testSession.skinSiteStatus.value = 'checking'
testSession.skinSiteUser.value = null
requests = 0
testGlobal.skinSiteAnnouncementFetch = async () => {
requests++
return { success: true, payload: [{ id: 1, title: '公告', updateAt: 1, star: false }] }
}
})
test('announcements wait for startup renewal and reload when sign-in finishes', async (t) => {
const state = mount(t)
await flush()
assert.equal(requests, 0)
assert.equal(state.loading, true)
testSession.skinSiteUser.value = { uuid: 'owner' }
testSession.skinSiteStatus.value = 'signed-in'
await flush()
assert.equal(requests, 1)
assert.equal(state.announcements.length, 1)
})
test('signed-out users do not request protected announcements or see a load failure', async (t) => {
testSession.skinSiteStatus.value = 'signed-out'
const state = mount(t)
await flush()
assert.equal(requests, 0)
assert.equal(state.loading, false)
assert.equal(state.failed, false)
})
test('signing out clears announcements and ignores an old in-flight response', async (t) => {
testSession.skinSiteStatus.value = 'signed-in'
let finish!: (value: unknown) => void
testGlobal.skinSiteAnnouncementFetch = () =>
new Promise((resolve) => {
finish = resolve
})
const state = mount(t)
await flush()
testSession.skinSiteStatus.value = 'signed-out'
await flush()
finish({ success: true, payload: [{ id: 2 }] })
await flush()
assert.deepEqual(state.announcements, [])
assert.equal(state.failed, false)
})
test('a failed announcement request can be retried', async (t) => {
testSession.skinSiteStatus.value = 'signed-in'
testGlobal.skinSiteAnnouncementFetch = async () => ({ success: false })
const state = mount(t)
await flush()
assert.equal(state.failed, true)
testGlobal.skinSiteAnnouncementFetch = async () => ({ success: true, payload: [{ id: 3 }] })
await state.load()
assert.equal(state.failed, false)
assert.equal(state.announcements[0].id, 3)
})
@@ -1,8 +1,13 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, useVIntl } from '@modrinth/ui'
import { onMounted, ref } from 'vue'
import { commonMessages, defineMessages, useVIntl } from '@modrinth/ui'
import { onUnmounted, ref, watch } from 'vue'
import {
reloadSkinSiteSession,
skinSiteStatus,
skinSiteUser,
} from '@/composables/skin-site-session'
import { type SkinSiteAnnouncement, skinSiteAnnouncements } from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
@@ -18,11 +23,16 @@ const messages = defineMessages({
id: 'app.skin-site.home.announcements.failed',
defaultMessage: 'Could not load announcements.',
},
signedOut: {
id: 'app.skin-site.home.announcements.signed-out',
defaultMessage: 'Sign in to view announcements.',
},
})
const announcements = ref<SkinSiteAnnouncement[]>([])
const loading = ref(true)
const failed = ref(false)
let requestId = 0
function formatDate(ts: number): string {
try {
@@ -32,9 +42,16 @@ function formatDate(ts: number): string {
}
}
onMounted(async () => {
async function load() {
const currentRequest = ++requestId
announcements.value = []
failed.value = skinSiteStatus.value === 'error'
loading.value = skinSiteStatus.value === 'checking'
if (skinSiteStatus.value !== 'signed-in') return
loading.value = true
try {
const result = await skinSiteAnnouncements()
if (currentRequest !== requestId) return
if (result.success) {
announcements.value = [...(result.payload ?? [])]
.sort((a, b) => {
@@ -46,10 +63,20 @@ onMounted(async () => {
failed.value = true
}
} catch {
failed.value = true
if (currentRequest === requestId) failed.value = true
} finally {
loading.value = false
if (currentRequest === requestId) loading.value = false
}
}
async function retry() {
if (skinSiteStatus.value === 'error') await reloadSkinSiteSession()
else await load()
}
watch([skinSiteStatus, () => skinSiteUser.value?.uuid], load, { immediate: true })
onUnmounted(() => {
requestId++
})
</script>
@@ -63,8 +90,14 @@ onMounted(async () => {
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p v-else-if="failed" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.failed) }}
<div v-else-if="failed" class="flex items-center gap-2 text-sm text-secondary">
<p class="m-0">{{ formatMessage(messages.failed) }}</p>
<button type="button" class="text-brand hover:underline" @click="retry">
{{ formatMessage(commonMessages.retryButton) }}
</button>
</div>
<p v-else-if="skinSiteStatus === 'signed-out'" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.signedOut) }}
</p>
<p v-else-if="!announcements.length" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.empty) }}
@@ -7,6 +7,16 @@ import {
skinSiteWikiAudit,
type SkinSiteWikiAuditInfo,
type SkinSiteWikiEditPolicy,
type SkinSiteWikiMigrationCandidate,
skinSiteWikiMigrationCandidates,
skinSiteWikiMigrationDecide,
skinSiteWikiMigrationDelete,
type SkinSiteWikiMigrationJobInfo,
skinSiteWikiMigrationJobs,
skinSiteWikiMigrationPublish,
skinSiteWikiMigrationRollback,
skinSiteWikiMigrationStartAutomatic,
skinSiteWikiMigrationStartXml,
skinSiteWikiPage,
type SkinSiteWikiPageDetail,
skinSiteWikiProtectPage,
@@ -16,22 +26,18 @@ import {
type SkinSiteWikiSettings,
skinSiteWikiSettings,
skinSiteWikiUpdateSettings,
skinSiteWikiMigrationCandidates,
skinSiteWikiMigrationDecide,
skinSiteWikiMigrationDelete,
skinSiteWikiMigrationJobs,
skinSiteWikiMigrationPublish,
skinSiteWikiMigrationRollback,
skinSiteWikiMigrationStartAutomatic,
skinSiteWikiMigrationStartXml,
type SkinSiteWikiMigrationCandidate,
type SkinSiteWikiMigrationJobInfo,
} from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const messages = defineMessages({
policyPublic: { id: 'app.skin-site.admin.wiki.policy-public', defaultMessage: 'Everyone' },
policyMember: { id: 'app.skin-site.admin.wiki.policy-member', defaultMessage: 'Members' },
policyAdmin: {
id: 'app.skin-site.admin.wiki.policy-admin',
defaultMessage: 'Website administrators',
},
title: { id: 'app.skin-site.admin.wiki.title', defaultMessage: 'Wiki' },
tabPage: { id: 'app.skin-site.admin.wiki.tab-page', defaultMessage: 'Page' },
tabAudit: { id: 'app.skin-site.admin.wiki.tab-audit', defaultMessage: 'Audit log' },
@@ -61,7 +67,8 @@ const messages = defineMessages({
},
credentialsHint: {
id: 'app.skin-site.admin.wiki.credentials-hint',
defaultMessage: 'Credentials stay in memory for this job only. HTTPS is required when supplied.',
defaultMessage:
'Credentials stay in memory for this job only. HTTPS is required when supplied.',
},
allowPrivate: {
id: 'app.skin-site.admin.wiki.allow-private',
@@ -529,8 +536,7 @@ async function loadMigrationJobs() {
if (!res.success) throw new Error(res.errorMessage)
migrationJobs.value = res.payload ?? []
if (selectedJob.value) {
selectedJob.value =
migrationJobs.value.find((j) => j.id === selectedJob.value!.id) ?? null
selectedJob.value = migrationJobs.value.find((j) => j.id === selectedJob.value!.id) ?? null
}
} catch (error) {
handleError(error)
@@ -821,8 +827,8 @@ onMounted(() => void loadSettings())
v-model="protectRead"
class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast"
>
<option value="PUBLIC">PUBLIC</option>
<option value="MEMBER">MEMBER</option>
<option value="PUBLIC">{{ formatMessage(messages.policyPublic) }}</option>
<option value="MEMBER">{{ formatMessage(messages.policyMember) }}</option>
</select>
</label>
<label class="flex flex-col gap-1">
@@ -833,8 +839,8 @@ onMounted(() => void loadSettings())
v-model="protectEdit"
class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast"
>
<option value="MEMBER">MEMBER</option>
<option value="WEB_ADMIN">WEB_ADMIN</option>
<option value="MEMBER">{{ formatMessage(messages.policyMember) }}</option>
<option value="WEB_ADMIN">{{ formatMessage(messages.policyAdmin) }}</option>
</select>
</label>
<button
@@ -1045,24 +1051,60 @@ onMounted(() => void loadSettings())
</summary>
<div class="mt-3 grid gap-2 sm:grid-cols-3">
<label class="flex flex-col gap-1">
<span class="text-[11px] text-secondary">{{ formatMessage(messages.staleDays) }}</span>
<input v-model.number="automaticForm.rules.staleDays" type="number" min="1" max="36500" class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast" />
<span class="text-[11px] text-secondary">{{
formatMessage(messages.staleDays)
}}</span>
<input
v-model.number="automaticForm.rules.staleDays"
type="number"
min="1"
max="36500"
class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-[11px] text-secondary">{{ formatMessage(messages.maintainerWindow) }}</span>
<input v-model.number="automaticForm.rules.maintainerWindowDays" type="number" min="1" max="36500" class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast" />
<span class="text-[11px] text-secondary">{{
formatMessage(messages.maintainerWindow)
}}</span>
<input
v-model.number="automaticForm.rules.maintainerWindowDays"
type="number"
min="1"
max="36500"
class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-[11px] text-secondary">{{ formatMessage(messages.maxMaintainers) }}</span>
<input v-model.number="automaticForm.rules.maximumMaintainers" type="number" min="0" max="100" class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast" />
<span class="text-[11px] text-secondary">{{
formatMessage(messages.maxMaintainers)
}}</span>
<input
v-model.number="automaticForm.rules.maximumMaintainers"
type="number"
min="0"
max="100"
class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast"
/>
</label>
<label class="flex flex-col gap-1 sm:col-span-3">
<span class="text-[11px] text-secondary">{{ formatMessage(messages.staleMarkers) }}</span>
<input v-model="staleMarkersText" type="text" :placeholder="formatMessage(messages.staleMarkersHint)" class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast" />
<span class="text-[11px] text-secondary">{{
formatMessage(messages.staleMarkers)
}}</span>
<input
v-model="staleMarkersText"
type="text"
:placeholder="formatMessage(messages.staleMarkersHint)"
class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast"
/>
</label>
</div>
</details>
<button type="button" class="rounded-lg bg-brand px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110 disabled:opacity-50" :disabled="startingMigration" @click="startAutomaticMigration">
<button
type="button"
class="rounded-lg bg-brand px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="startingMigration"
@click="startAutomaticMigration"
>
{{ formatMessage(messages.startAuto) }}
</button>
</div>
@@ -1075,9 +1117,19 @@ onMounted(() => void loadSettings())
<span class="text-xs font-semibold text-secondary">
{{ formatMessage(messages.xmlFile) }}
</span>
<input ref="xmlInput" type="file" accept=".xml,text/xml,application/xml" class="text-xs text-contrast" />
<input
ref="xmlInput"
type="file"
accept=".xml,text/xml,application/xml"
class="text-xs text-contrast"
/>
</label>
<button type="button" class="rounded-lg bg-brand px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110 disabled:opacity-50" :disabled="startingMigration" @click="startXmlMigration">
<button
type="button"
class="rounded-lg bg-brand px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="startingMigration"
@click="startXmlMigration"
>
{{ formatMessage(messages.startXml) }}
</button>
</div>
@@ -1087,16 +1139,27 @@ onMounted(() => void loadSettings())
<h4 class="m-0 text-sm font-bold text-contrast">
{{ formatMessage(messages.jobs) }}
</h4>
<button type="button" class="rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50" :disabled="jobsLoading" @click="loadMigrationJobs">
<button
type="button"
class="rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="jobsLoading"
@click="loadMigrationJobs"
>
{{ formatMessage(messages.refreshJobs) }}
</button>
</div>
<div v-if="jobsLoading" class="flex min-h-24 items-center justify-center gap-2 text-sm text-secondary">
<div
v-if="jobsLoading"
class="flex min-h-24 items-center justify-center gap-2 text-sm text-secondary"
>
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p v-else-if="!migrationJobs.length" class="m-0 rounded-lg bg-bg-raised p-3 text-xs text-secondary">
<p
v-else-if="!migrationJobs.length"
class="m-0 rounded-lg bg-bg-raised p-3 text-xs text-secondary"
>
{{ formatMessage(messages.jobsEmpty) }}
</p>
<div v-else class="flex flex-col gap-2">
@@ -1107,21 +1170,43 @@ onMounted(() => void loadSettings())
:class="selectedJob?.id === job.id ? 'border-brand bg-brand/5' : 'border-surface-5'"
>
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="text-sm font-semibold text-contrast">#{{ job.id }} · {{ job.source }}</span>
<span class="rounded-full px-2 py-0.5 text-[10px] font-semibold" :class="jobStateClass(job.state)">
<span class="text-sm font-semibold text-contrast"
>#{{ job.id }} · {{ job.source }}</span
>
<span
class="rounded-full px-2 py-0.5 text-[10px] font-semibold"
:class="jobStateClass(job.state)"
>
{{ jobStateLabel(job.state) }}
</span>
</div>
<div class="mt-2 h-1.5 overflow-hidden rounded-full bg-button-bg">
<div class="h-full bg-brand transition-all" :style="{ width: (job.total ? (job.reviewed / job.total) * 100 : 0) + '%' }"></div>
<div
class="h-full bg-brand transition-all"
:style="{ width: (job.total ? (job.reviewed / job.total) * 100 : 0) + '%' }"
></div>
</div>
<div class="mt-2 flex flex-wrap justify-between gap-2 text-[11px] text-secondary">
<span>{{ formatMessage(messages.reviewedCount, { reviewed: job.reviewed, total: job.total }) }}</span>
<span>{{ formatMessage(messages.importedExcluded, { imported: job.imported, excluded: job.excluded }) }}</span>
<span>{{
formatMessage(messages.reviewedCount, {
reviewed: job.reviewed,
total: job.total,
})
}}</span>
<span>{{
formatMessage(messages.importedExcluded, {
imported: job.imported,
excluded: job.excluded,
})
}}</span>
</div>
<p v-if="job.error" class="m-0 mt-2 text-xs text-red">{{ job.error }}</p>
<div class="mt-2 flex flex-wrap justify-end gap-2">
<button type="button" class="rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110" @click="selectJob(job)">
<button
type="button"
class="rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="selectJob(job)"
>
{{ formatMessage(messages.viewJob) }}
</button>
<button
@@ -1139,10 +1224,19 @@ onMounted(() => void loadSettings())
{{ formatMessage(messages.confirmDeleteJob, { id: job.id }) }}
</p>
<div class="mt-2 flex justify-end gap-2">
<button type="button" class="rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-secondary" @click="confirmingDeleteId = null">
<button
type="button"
class="rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-secondary"
@click="confirmingDeleteId = null"
>
{{ formatMessage(messages.cancel) }}
</button>
<button type="button" class="rounded-lg bg-red px-3 py-1 text-xs font-semibold text-contrast disabled:opacity-50" :disabled="deletingJob" @click="deleteJob(job)">
<button
type="button"
class="rounded-lg bg-red px-3 py-1 text-xs font-semibold text-contrast disabled:opacity-50"
:disabled="deletingJob"
@click="deleteJob(job)"
>
{{ formatMessage(messages.deleteJob) }}
</button>
</div>
@@ -1151,7 +1245,10 @@ onMounted(() => void loadSettings())
</div>
</div>
<div v-if="selectedJob" class="flex flex-col gap-3 rounded-xl border border-solid border-surface-5 p-4">
<div
v-if="selectedJob"
class="flex flex-col gap-3 rounded-xl border border-solid border-surface-5 p-4"
>
<div class="flex flex-wrap items-center justify-between gap-2">
<h4 class="m-0 text-sm font-bold text-contrast">
{{ formatMessage(messages.candidates) }}
@@ -1180,20 +1277,35 @@ onMounted(() => void loadSettings())
<div v-if="confirmingRollbackId === selectedJob.id" class="rounded-lg bg-yellow/10 p-2">
<p class="m-0 text-xs text-yellow">{{ formatMessage(messages.confirmRollback) }}</p>
<div class="mt-2 flex justify-end gap-2">
<button type="button" class="rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-secondary" @click="confirmingRollbackId = null">
<button
type="button"
class="rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-secondary"
@click="confirmingRollbackId = null"
>
{{ formatMessage(messages.cancel) }}
</button>
<button type="button" class="rounded-lg bg-yellow px-3 py-1 text-xs font-semibold text-contrast disabled:opacity-50" :disabled="rollingBack" @click="rollbackMigration">
<button
type="button"
class="rounded-lg bg-yellow px-3 py-1 text-xs font-semibold text-contrast disabled:opacity-50"
:disabled="rollingBack"
@click="rollbackMigration"
>
{{ formatMessage(messages.rollback) }}
</button>
</div>
</div>
<div v-if="candidatesLoading && !candidates.length" class="flex min-h-24 items-center justify-center gap-2 text-sm text-secondary">
<div
v-if="candidatesLoading && !candidates.length"
class="flex min-h-24 items-center justify-center gap-2 text-sm text-secondary"
>
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p v-else-if="!candidates.length" class="m-0 rounded-lg bg-bg-raised p-3 text-xs text-secondary">
<p
v-else-if="!candidates.length"
class="m-0 rounded-lg bg-bg-raised p-3 text-xs text-secondary"
>
{{ formatMessage(messages.candidatesEmpty) }}
</p>
<div v-else class="flex flex-col gap-2">
@@ -1203,20 +1315,38 @@ onMounted(() => void loadSettings())
class="flex flex-col gap-2 rounded-lg border border-solid border-surface-5 p-3"
>
<div class="flex flex-wrap items-center gap-2">
<span class="min-w-0 flex-1 truncate text-sm font-semibold text-contrast">{{ candidate.title }}</span>
<span class="rounded-full px-2 py-0.5 text-[10px] font-semibold" :class="decisionClass(candidate.decision)">
<span class="min-w-0 flex-1 truncate text-sm font-semibold text-contrast">{{
candidate.title
}}</span>
<span
class="rounded-full px-2 py-0.5 text-[10px] font-semibold"
:class="decisionClass(candidate.decision)"
>
{{ decisionLabel(candidate.decision) }}
</span>
<span v-if="candidate.conflict" class="rounded-full bg-yellow/20 px-2 py-0.5 text-[10px] font-semibold text-yellow">
<span
v-if="candidate.conflict"
class="rounded-full bg-yellow/20 px-2 py-0.5 text-[10px] font-semibold text-yellow"
>
{{ formatMessage(messages.conflict) }}
</span>
</div>
<div class="flex flex-wrap gap-3 text-[11px] text-secondary">
<span>{{ formatMessage(messages.lastUpdated, { date: new Date(candidate.lastUpdatedAt).toLocaleDateString() }) }}</span>
<span>{{ formatMessage(messages.maintainerCount, { count: candidate.maintainerCount }) }}</span>
<span>{{
formatMessage(messages.lastUpdated, {
date: new Date(candidate.lastUpdatedAt).toLocaleDateString(),
})
}}</span>
<span>{{
formatMessage(messages.maintainerCount, { count: candidate.maintainerCount })
}}</span>
</div>
<div v-if="candidate.suspiciousReasons.length" class="flex flex-wrap gap-1">
<span v-for="reason in candidate.suspiciousReasons" :key="reason" class="rounded bg-bg px-1.5 py-0.5 text-[10px] text-secondary">
<span
v-for="reason in candidate.suspiciousReasons"
:key="reason"
class="rounded bg-bg px-1.5 py-0.5 text-[10px] text-secondary"
>
{{ reason }}
</span>
</div>
@@ -1228,13 +1358,25 @@ onMounted(() => void loadSettings())
class="min-w-0 flex-1 rounded-lg border border-solid border-surface-5 bg-bg-raised px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
/>
<label class="flex cursor-pointer items-center gap-1.5 text-[11px] text-contrast">
<input v-model="candidateOverwrite[candidate.id]" type="checkbox" class="size-3" />
<input
v-model="candidateOverwrite[candidate.id]"
type="checkbox"
class="size-3"
/>
{{ formatMessage(messages.overwrite) }}
</label>
<button type="button" class="rounded-lg bg-green/20 px-3 py-1 text-xs font-semibold text-green hover:brightness-110" @click="decideCandidate(candidate, 'IMPORT')">
<button
type="button"
class="rounded-lg bg-green/20 px-3 py-1 text-xs font-semibold text-green hover:brightness-110"
@click="decideCandidate(candidate, 'IMPORT')"
>
{{ formatMessage(messages.importAction) }}
</button>
<button type="button" class="rounded-lg bg-red/20 px-3 py-1 text-xs font-semibold text-red hover:brightness-110" @click="decideCandidate(candidate, 'EXCLUDE')">
<button
type="button"
class="rounded-lg bg-red/20 px-3 py-1 text-xs font-semibold text-red hover:brightness-110"
@click="decideCandidate(candidate, 'EXCLUDE')"
>
{{ formatMessage(messages.excludeAction) }}
</button>
</div>
@@ -1251,8 +1393,6 @@ onMounted(() => void loadSettings())
</div>
</div>
</div>
</template>
</template>
</div>
</template>
@@ -0,0 +1,208 @@
import assert from 'node:assert/strict'
import { registerHooks } from 'node:module'
import test from 'node:test'
type FetchHandler = (url: string, options: RequestInit) => Promise<Response>
const testGlobal = globalThis as unknown as { skinSiteTestFetch: FetchHandler }
const hooks = registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier === '@tauri-apps/plugin-http') {
return {
url: 'data:text/javascript,export const fetch=(...args)=>globalThis.skinSiteTestFetch(...args)',
shortCircuit: true,
}
}
if (specifier.startsWith('@/')) {
return nextResolve(new URL(`../${specifier.slice(2)}.ts`, import.meta.url).href, context)
}
return nextResolve(specifier, context)
},
})
const session = await import('./skin-site-session.ts')
hooks.deregister()
const user = { uuid: 'owner', username: 'Player', permissions: [] }
const ok = (payload: unknown) => Response.json({ payload })
const storage = new Map<string, string>()
const tokenKey = 'starlight-skin-site-token'
let calls: Array<{ path: string; token: string | undefined }> = []
let respond: FetchHandler
test.beforeEach(() => {
storage.clear()
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
},
})
calls = []
respond = async (url) => {
if (url.endsWith('/user/refresh')) return ok({ username: 'Player', jwt: 'fresh-token' })
if (url.endsWith('/user')) return ok(user)
return ok([])
}
testGlobal.skinSiteTestFetch = async (url, options) => {
calls.push({
path: new URL(url).pathname,
token: (options.headers as Record<string, string>)?.Authorization,
})
return respond(url, options)
}
session.resetSkinSiteSession()
})
test.afterEach(async () => {
session.signOutSkinSite()
await new Promise((resolve) => setImmediate(resolve))
Reflect.deleteProperty(globalThis, 'localStorage')
})
test('startup renews the stored JWT before loading the user with the new token', async () => {
storage.set(tokenKey, 'old-token')
await session.initSkinSiteSession()
assert.deepEqual(calls.slice(0, 2), [
{ path: '/starlight/user/refresh', token: 'Bearer old-token' },
{ path: '/starlight/user', token: 'Bearer fresh-token' },
])
assert.equal(storage.get(tokenKey), 'fresh-token')
assert.equal(session.skinSiteStatus.value, 'signed-in')
})
test('simultaneous startup consumers share one refresh', async () => {
storage.set(tokenKey, 'old-token')
await Promise.all([
session.initSkinSiteSession(),
session.initSkinSiteSession(),
session.waitForSkinSiteSession(),
])
assert.equal(calls.filter((call) => call.path.endsWith('/refresh')).length, 1)
})
test('startup without a stored token does not make authenticated requests', async () => {
await session.initSkinSiteSession()
assert.equal(session.skinSiteStatus.value, 'signed-out')
assert.deepEqual(calls, [])
})
test('an expired token is cleared when renewal is rejected', async () => {
storage.set(tokenKey, 'expired-token')
respond = async () => Response.json({ errorMessage: 'Expired' }, { status: 401 })
await session.initSkinSiteSession()
assert.equal(storage.has(tokenKey), false)
assert.equal(session.skinSiteStatus.value, 'signed-out')
assert.equal(calls.length, 1)
})
test('temporary renewal failure preserves the token and allows startup retry', async () => {
storage.set(tokenKey, 'old-token')
respond = async () => Response.json({ errorMessage: 'Unavailable' }, { status: 503 })
await session.initSkinSiteSession()
assert.equal(session.skinSiteStatus.value, 'error')
assert.equal(storage.get(tokenKey), 'old-token')
respond = async (url) =>
url.endsWith('/refresh')
? ok({ jwt: 'fresh-token' })
: url.endsWith('/user')
? ok(user)
: ok([])
await session.initSkinSiteSession()
assert.equal(session.skinSiteStatus.value, 'signed-in')
})
test('a late refresh cannot restore a session after sign-out', async () => {
storage.set(tokenKey, 'old-token')
let finish!: (response: Response) => void
respond = () =>
new Promise((resolve) => {
finish = resolve
})
const initializing = session.initSkinSiteSession()
await new Promise((resolve) => setImmediate(resolve))
session.signOutSkinSite()
finish(ok({ username: 'Player', jwt: 'late-token' }))
await initializing
assert.equal(storage.has(tokenKey), false)
assert.equal(session.skinSiteStatus.value, 'signed-out')
assert.equal(session.skinSiteUser.value, null)
})
test('a malformed refresh response never replaces the saved token', async () => {
storage.set(tokenKey, 'old-token')
respond = async () => ok({ username: 'Player' })
await session.initSkinSiteSession()
assert.equal(storage.get(tokenKey), 'old-token')
assert.equal(session.skinSiteStatus.value, 'error')
})
test('a failed sign-in attempt does not invalidate a pending startup renewal', async () => {
storage.set(tokenKey, 'old-token')
let finish!: (response: Response) => void
respond = async (url) => {
if (url.endsWith('/refresh'))
return new Promise((resolve) => {
finish = resolve
})
if (url.endsWith('/login'))
return Response.json({ errorMessage: 'Invalid password' }, { status: 401 })
return url.endsWith('/user') ? ok(user) : ok([])
}
const initializing = session.initSkinSiteSession()
await new Promise((resolve) => setImmediate(resolve))
await session.signInSkinSite({ username: 'Player', password: 'wrong', clientId: 'captcha' })
finish(ok({ username: 'Player', jwt: 'fresh-token' }))
await initializing
assert.equal(session.skinSiteStatus.value, 'signed-in')
assert.equal(storage.get(tokenKey), 'fresh-token')
})
test('rejection of an old startup token does not discard a new successful login', async () => {
storage.set(tokenKey, 'expired-token')
let finishRefresh!: (response: Response) => void
let finishLogin!: (response: Response) => void
respond = async (url) => {
if (url.endsWith('/refresh'))
return new Promise((resolve) => {
finishRefresh = resolve
})
if (url.endsWith('/login'))
return new Promise((resolve) => {
finishLogin = resolve
})
return url.endsWith('/user') ? ok(user) : ok([])
}
const initializing = session.initSkinSiteSession()
const signingIn = session.signInSkinSite({
username: 'Player',
password: 'valid',
clientId: 'captcha',
})
await new Promise((resolve) => setImmediate(resolve))
finishRefresh(Response.json({ errorMessage: 'Expired' }, { status: 401 }))
await initializing
finishLogin(ok({ username: 'Player', jwt: 'login-token' }))
await signingIn
assert.equal(session.skinSiteStatus.value, 'signed-in')
assert.equal(storage.get(tokenKey), 'login-token')
})
test('explicit sign-out discards a late successful login', async () => {
let finishLogin!: (response: Response) => void
respond = () =>
new Promise((resolve) => {
finishLogin = resolve
})
const signingIn = session.signInSkinSite({
username: 'Player',
password: 'valid',
clientId: 'captcha',
})
await new Promise((resolve) => setImmediate(resolve))
session.signOutSkinSite()
finishLogin(ok({ username: 'Player', jwt: 'late-login-token' }))
await signingIn
assert.equal(session.skinSiteStatus.value, 'signed-out')
assert.equal(storage.has(tokenKey), false)
})
@@ -65,13 +65,54 @@ function clearSessionState() {
// ---------------------------------------------------------------------------
let initialized: Promise<void> | null = null
let sessionRevision = 0
let loginRevision = 0
let refreshRequest: { token: string; revision: number; promise: Promise<boolean> } | null = null
/** Share renewals and discard results from a session that has since changed. */
function renewSkinSiteToken(): Promise<boolean> {
const token = getSkinSiteToken()
if (!token) return Promise.resolve(false)
const revision = sessionRevision
if (refreshRequest?.token === token && refreshRequest.revision === revision) {
return refreshRequest.promise
}
const request = {
token,
revision,
promise: (async () => {
let result: SkinSiteResponse<{ username: string; jwt: string }>
try {
result = await skinSiteRefresh()
} catch {
return false
}
if (revision !== sessionRevision || getSkinSiteToken() !== token) return false
if (result.success) {
if (typeof result.payload?.jwt !== 'string' || !result.payload.jwt.trim()) return false
setSkinSiteToken(result.payload.jwt)
return true
}
if (result.errorCode === 401 || result.errorCode === 403) invalidateSkinSiteSession()
return false
})(),
}
refreshRequest = request
const clearRequest = () => {
if (refreshRequest === request) refreshRequest = null
}
void request.promise.then(clearRequest, clearRequest)
return request.promise
}
/**
* Resolve the current session from the stored JWT. Safe to call repeatedly;
* Renew the saved JWT on each launcher startup before loading the user.
* Safe to call repeatedly;
* the first call performs the actual work and later ones reuse it.
*/
export function initSkinSiteSession(): Promise<void> {
if (initialized) return initialized
const revision = sessionRevision
initialized = (async () => {
const token = getSkinSiteToken()
if (!token) {
@@ -79,7 +120,13 @@ export function initSkinSiteSession(): Promise<void> {
return
}
status.value = 'checking'
if (!(await renewSkinSiteToken())) {
if (revision === sessionRevision) status.value = 'error'
return
}
if (revision !== sessionRevision) return
const result = await skinSiteCurrentUser()
if (revision !== sessionRevision) return
if (result.success) {
user.value = toUser(result.payload)
status.value = 'signed-in'
@@ -92,11 +139,18 @@ export function initSkinSiteSession(): Promise<void> {
status.value = 'error'
}
})()
.catch(() => {
if (revision === sessionRevision) status.value = 'error'
})
.finally(() => {
if (revision === sessionRevision && status.value === 'error') initialized = null
})
return initialized
}
/** Force a re-check (e.g. after the user signs in from another view). */
export function reloadSkinSiteSession(): Promise<void> {
sessionRevision++
initialized = null
return initSkinSiteSession()
}
@@ -106,10 +160,14 @@ export async function signInSkinSite(payload: {
password: string
clientId: string
}): Promise<SkinSiteResponse<{ username: string; jwt: string }>> {
const attemptRevision = ++loginRevision
const result = await skinSiteLogin(payload)
if (result.success) {
if (result.success && attemptRevision === loginRevision) {
const revision = ++sessionRevision
initialized = null
setSkinSiteToken(result.payload.jwt)
const current = await skinSiteCurrentUser()
if (revision !== sessionRevision) return result
if (current.success) {
user.value = toUser(current.payload)
status.value = 'signed-in'
@@ -122,23 +180,25 @@ export async function signInSkinSite(payload: {
return result
}
export function signOutSkinSite() {
/** Reject an old credential without cancelling a newer explicit login attempt. */
function invalidateSkinSiteSession() {
sessionRevision++
refreshRequest = null
clearSkinSiteToken()
clearSessionState()
status.value = 'signed-out'
initialized = null
}
export function signOutSkinSite() {
loginRevision++
invalidateSkinSiteSession()
}
/** Refresh the JWT; returns true when a new token was stored. */
export async function refreshSkinSiteSession(): Promise<boolean> {
if (status.value !== 'signed-in') return false
const result = await skinSiteRefresh()
if (result.success) {
setSkinSiteToken(result.payload.jwt)
return true
}
if (result.errorCode === 401 || result.errorCode === 403) signOutSkinSite()
return false
return renewSkinSiteToken()
}
// ---------------------------------------------------------------------------
@@ -157,6 +217,9 @@ export function receiveSkinSiteMessage(_event: MessageEvent, _frame: Window | nu
export function openSkinSiteLogin() {}
export function resetSkinSiteSession() {
loginRevision++
sessionRevision++
refreshRequest = null
clearSessionState()
status.value = getSkinSiteToken() ? 'checking' : 'signed-out'
initialized = null
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+17
View File
@@ -1,5 +1,6 @@
import { readFile } from 'node:fs/promises'
import { parse, TYPE } from '@formatjs/icu-messageformat-parser'
import { collectSkinSiteMessages } from './skin-site-messages.mjs'
const localePairs = [
[
@@ -10,6 +11,22 @@ const localePairs = [
]
const failures = []
const skinSiteCatalogs = Object.fromEntries(
await Promise.all(
['en-US', 'zh-CN'].map(async (locale) => [
locale,
JSON.parse(await readFile(`apps/app-frontend/src/locales/${locale}/index.json`, 'utf8')),
]),
),
)
for (const { id, file } of collectSkinSiteMessages()) {
for (const locale of ['en-US', 'zh-CN']) {
const catalogPath = `apps/app-frontend/src/locales/${locale}/index.json`
const catalog = skinSiteCatalogs[locale]
if (!catalog[id]?.message)
failures.push(`${catalogPath}: missing source message ${id} (${file})`)
}
}
const allowedUntranslatedMessages = new Set([
'Chaos Cubed',
'MINECON Earth 2017',
+36
View File
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import test from 'node:test'
import { collectSkinSiteMessages } from './skin-site-messages.mjs'
test('all skin-site messages used in source exist in both release language catalogs', () => {
const messages = collectSkinSiteMessages()
assert.ok(messages.length > 100, 'The check must scan the actual UI')
for (const locale of ['en-US', 'zh-CN']) {
const catalog = JSON.parse(
fs.readFileSync(`apps/app-frontend/src/locales/${locale}/index.json`, 'utf8'),
)
const missing = messages.filter(({ id }) => !catalog[id]?.message)
assert.deepEqual(
missing.map(({ id }) => id),
[],
`${locale} has uncatalogued UI messages`,
)
}
})
test('Chinese login and announcement labels do not fall back to English', () => {
const catalog = JSON.parse(
fs.readFileSync('apps/app-frontend/src/locales/zh-CN/index.json', 'utf8'),
)
for (const id of [
'app.skin-site.login.title',
'app.skin-site.login.username',
'app.skin-site.login.password-hint',
'app.skin-site.home.announcements.title',
'app.skin-site.home.announcements.failed',
]) {
assert.match(catalog[id]?.message ?? '', /\p{Script=Han}/u, id)
}
})
+59
View File
@@ -0,0 +1,59 @@
import fs from 'node:fs'
import path from 'node:path'
import { createRequire } from 'node:module'
const require = createRequire(new URL('../../apps/app-frontend/package.json', import.meta.url))
const ts = require('typescript')
const { parse } = require('vue/compiler-sfc')
/** Read the message IDs actually used by skin-site UI, including uncatalogued messages. */
export function collectSkinSiteMessages(root = 'apps/app-frontend/src') {
const messages = new Map()
function walk(directory) {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const file = path.join(directory, entry.name)
if (entry.isDirectory()) {
if (entry.name !== 'locales') walk(file)
continue
}
if (!/\.(vue|ts)$/.test(file) || file.endsWith('.test.ts')) continue
let source = fs.readFileSync(file, 'utf8')
if (file.endsWith('.vue')) {
const { descriptor } = parse(source)
source = [descriptor.script?.content, descriptor.scriptSetup?.content]
.filter(Boolean)
.join('\n')
}
const ast = ts.createSourceFile(`${file}.ts`, source, ts.ScriptTarget.Latest, true)
function visit(node) {
if (ts.isObjectLiteralExpression(node)) {
const properties = Object.fromEntries(
node.properties
.filter(ts.isPropertyAssignment)
.map((property) => [
property.name.getText(ast).replace(/^['"]|['"]$/g, ''),
property.initializer,
]),
)
if (
properties.id &&
properties.defaultMessage &&
ts.isStringLiteralLike(properties.id) &&
ts.isStringLiteralLike(properties.defaultMessage) &&
properties.id.text.startsWith('app.skin-site.')
) {
messages.set(properties.id.text, {
id: properties.id.text,
defaultMessage: properties.defaultMessage.text,
file,
})
}
}
ts.forEachChild(node, visit)
}
visit(ast)
}
}
walk(root)
return [...messages.values()]
}