feat(fc3b): /credentials view + ExtensionKeyBar + PlatformCredentialRow + UploadDialog

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 18:38:32 -04:00
parent e83747b085
commit a0203f4727
5 changed files with 300 additions and 1 deletions
@@ -0,0 +1,76 @@
<template>
<v-dialog :model-value="modelValue" max-width="640"
@update:model-value="$emit('update:modelValue', $event)">
<v-card v-if="platform">
<v-card-title>
Upload {{ platform.name }} {{ platform.auth_type }}
</v-card-title>
<v-card-text>
<v-textarea
v-if="platform.auth_type === 'cookies'"
v-model="text"
label="Netscape cookies.txt content"
rows="8" auto-grow spellcheck="false"
:error-messages="error"
placeholder="# Netscape HTTP Cookie File&#10;.patreon.com&#9;TRUE&#9;/&#9;TRUE&#9;1700000000&#9;session_id&#9;..."
/>
<v-text-field
v-else
v-model="text"
label="Token"
type="password"
:error-messages="error"
/>
<p v-if="platform.notes" class="text-caption mt-2" style="opacity: 0.75">
{{ platform.notes }}
</p>
<p class="text-caption mt-2" style="opacity: 0.6">
Tip: the GallerySubscriber browser extension can push this automatically.
</p>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="$emit('update:modelValue', false)">Cancel</v-btn>
<v-btn color="accent" variant="flat" :loading="busy" :disabled="!text.trim()" @click="save">
Save
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { ref, watch } from 'vue'
import { useCredentialsStore } from '../../stores/credentials.js'
const props = defineProps({
modelValue: { type: Boolean, default: false },
platform: { type: Object, default: null },
})
const emit = defineEmits(['update:modelValue', 'saved'])
const store = useCredentialsStore()
const text = ref('')
const error = ref('')
const busy = ref(false)
watch(() => props.modelValue, (open) => {
if (open) { text.value = ''; error.value = '' }
})
async function save() {
if (!props.platform) return
error.value = ''
if (!text.value.trim()) { error.value = 'Required'; return }
busy.value = true
try {
await store.upload(props.platform.key, props.platform.auth_type, text.value)
emit('saved')
emit('update:modelValue', false)
} catch (e) {
error.value = String(e?.detail || e?.message || e)
} finally {
busy.value = false
}
}
</script>
@@ -0,0 +1,75 @@
<template>
<div class="fc-extkey-bar">
<span class="fc-extkey-bar__label">Extension API key:</span>
<code class="fc-extkey-bar__value">{{ store.extensionKey || '…' }}</code>
<v-btn size="small" variant="text" @click="copyKey" :disabled="!store.extensionKey">
Copy
</v-btn>
<v-btn size="small" variant="outlined" color="warning" @click="confirmRotate">
Rotate
</v-btn>
<v-dialog v-model="showRotateConfirm" max-width="420">
<v-card>
<v-card-title>Rotate extension API key?</v-card-title>
<v-card-text>
The old key stops working immediately. Update your browser extension
with the new key after rotating.
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showRotateConfirm = false">Cancel</v-btn>
<v-btn color="warning" variant="flat" @click="doRotate">Rotate</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { useCredentialsStore } from '../../stores/credentials.js'
const store = useCredentialsStore()
const showRotateConfirm = ref(false)
onMounted(() => store.loadKey())
async function copyKey() {
if (!store.extensionKey) return
try {
await navigator.clipboard.writeText(store.extensionKey)
globalThis.window?.__fcToast?.({ text: 'Copied', type: 'success' })
} catch {
globalThis.window?.__fcToast?.({ text: 'Copy failed', type: 'error' })
}
}
function confirmRotate() { showRotateConfirm.value = true }
async function doRotate() {
showRotateConfirm.value = false
await store.rotateKey()
globalThis.window?.__fcToast?.({ text: 'Key rotated', type: 'success' })
}
</script>
<style scoped>
.fc-extkey-bar {
display: flex; align-items: center; gap: 0.75rem;
padding: 0.75rem 1rem; margin-bottom: 1rem;
background: rgb(var(--v-theme-surface));
border: 1px solid rgb(var(--v-theme-on-surface-variant) / 0.18);
border-radius: 6px;
}
.fc-extkey-bar__label {
color: rgb(var(--v-theme-on-surface-variant));
font-size: 0.85rem;
}
.fc-extkey-bar__value {
font-family: 'JetBrains Mono', monospace;
font-size: 0.85rem;
flex: 0 1 auto;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
@@ -0,0 +1,68 @@
<template>
<section class="fc-cred-row">
<header class="fc-cred-row__head">
<span class="fc-cred-row__name">{{ platform.name }}</span>
<v-chip size="x-small" variant="tonal">{{ platform.auth_type }}</v-chip>
</header>
<div class="fc-cred-row__body">
<template v-if="credential">
<span class="fc-cred-row__status">
Stored · captured {{ fmtDate(credential.captured_at) }}
<template v-if="credential.expires_at">
· expires {{ fmtDate(credential.expires_at) }}
</template>
</span>
<v-spacer />
<v-btn size="small" variant="outlined" @click="$emit('replace', platform)">
Replace
</v-btn>
<v-btn size="small" variant="text" color="error" @click="$emit('remove', platform)">
×
</v-btn>
</template>
<template v-else>
<span class="fc-cred-row__status fc-cred-row__status--empty"> No credential</span>
<v-spacer />
<v-btn size="small" variant="flat" color="accent" @click="$emit('replace', platform)">
{{ platform.auth_type === 'cookies' ? 'Paste cookies.txt' : 'Paste token' }}
</v-btn>
</template>
</div>
</section>
</template>
<script setup>
defineProps({
platform: { type: Object, required: true },
credential: { type: Object, default: null },
})
defineEmits(['replace', 'remove'])
function fmtDate(iso) {
if (!iso) return '—'
return iso.slice(0, 10)
}
</script>
<style scoped>
.fc-cred-row {
border: 1px solid rgb(var(--v-theme-on-surface-variant) / 0.18);
border-radius: 6px;
padding: 0.75rem 1rem;
margin-bottom: 0.75rem;
}
.fc-cred-row__head {
display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem;
}
.fc-cred-row__name { font-weight: 600; }
.fc-cred-row__body {
display: flex; align-items: center; gap: 0.5rem;
}
.fc-cred-row__status {
font-size: 0.9rem;
color: rgb(var(--v-theme-on-surface));
}
.fc-cred-row__status--empty {
color: rgb(var(--v-theme-on-surface-variant));
}
</style>