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:
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
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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()]
|
||||
}
|
||||
Reference in new issue
Block a user