feat(fc3b): /credentials view + ExtensionKeyBar + PlatformCredentialRow + UploadDialog
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 .patreon.com	TRUE	/	TRUE	1700000000	session_id	..."
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
@@ -8,6 +8,7 @@ import ArtistView from './views/ArtistView.vue'
|
|||||||
import SeriesManageView from './views/SeriesManageView.vue'
|
import SeriesManageView from './views/SeriesManageView.vue'
|
||||||
import SeriesReaderView from './views/SeriesReaderView.vue'
|
import SeriesReaderView from './views/SeriesReaderView.vue'
|
||||||
import SubscriptionsView from './views/SubscriptionsView.vue'
|
import SubscriptionsView from './views/SubscriptionsView.vue'
|
||||||
|
import CredentialsView from './views/CredentialsView.vue'
|
||||||
|
|
||||||
// The application's front door. `/` redirects here. Changing the front door
|
// The application's front door. `/` redirects here. Changing the front door
|
||||||
// is a one-line edit (e.g. '/gallery' or '/tags').
|
// is a one-line edit (e.g. '/gallery' or '/tags').
|
||||||
@@ -31,7 +32,7 @@ const routes = [
|
|||||||
|
|
||||||
// FC-3: subscription backbone
|
// FC-3: subscription backbone
|
||||||
{ path: '/subscriptions', name: 'subscriptions', component: SubscriptionsView, meta: { title: 'Subscriptions' } },
|
{ path: '/subscriptions', name: 'subscriptions', component: SubscriptionsView, meta: { title: 'Subscriptions' } },
|
||||||
{ path: '/credentials', name: 'credentials', component: PlaceholderView, meta: { title: 'Credentials' } },
|
{ path: '/credentials', name: 'credentials', component: CredentialsView, meta: { title: 'Credentials' } },
|
||||||
{ path: '/downloads', name: 'downloads', component: PlaceholderView, meta: { title: 'Downloads' } }
|
{ path: '/downloads', name: 'downloads', component: PlaceholderView, meta: { title: 'Downloads' } }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<template>
|
||||||
|
<v-container fluid class="py-6">
|
||||||
|
<ExtensionKeyBar />
|
||||||
|
|
||||||
|
<v-alert v-if="credentialsStore.error" type="error" variant="tonal" closable class="mb-4">
|
||||||
|
{{ String(credentialsStore.error) }}
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<PlatformCredentialRow
|
||||||
|
v-for="p in platformsStore.list"
|
||||||
|
:key="p.key"
|
||||||
|
:platform="p"
|
||||||
|
:credential="credentialsStore.byPlatform.get(p.key) || null"
|
||||||
|
@replace="openUpload"
|
||||||
|
@remove="confirmRemove"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CredentialUploadDialog
|
||||||
|
v-model="showUpload"
|
||||||
|
:platform="uploadPlatform"
|
||||||
|
@saved="onSaved"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-dialog v-model="removeConfirm.open" max-width="420">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>Delete {{ removeConfirm.platform?.name }} credential?</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
The encrypted credential will be removed permanently. You'll need to
|
||||||
|
re-upload to use this platform again.
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="text" @click="removeConfirm.open = false">Cancel</v-btn>
|
||||||
|
<v-btn color="error" variant="flat" @click="doRemove">Delete</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</v-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { usePlatformsStore } from '../stores/platforms.js'
|
||||||
|
import { useCredentialsStore } from '../stores/credentials.js'
|
||||||
|
import ExtensionKeyBar from '../components/credentials/ExtensionKeyBar.vue'
|
||||||
|
import PlatformCredentialRow from '../components/credentials/PlatformCredentialRow.vue'
|
||||||
|
import CredentialUploadDialog from '../components/credentials/CredentialUploadDialog.vue'
|
||||||
|
|
||||||
|
const platformsStore = usePlatformsStore()
|
||||||
|
const credentialsStore = useCredentialsStore()
|
||||||
|
|
||||||
|
const showUpload = ref(false)
|
||||||
|
const uploadPlatform = ref(null)
|
||||||
|
const removeConfirm = reactive({ open: false, platform: null })
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([platformsStore.loadAll(), credentialsStore.loadAll()])
|
||||||
|
})
|
||||||
|
|
||||||
|
function openUpload(platform) {
|
||||||
|
uploadPlatform.value = platform
|
||||||
|
showUpload.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSaved() {
|
||||||
|
await credentialsStore.loadAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmRemove(platform) {
|
||||||
|
removeConfirm.platform = platform
|
||||||
|
removeConfirm.open = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doRemove() {
|
||||||
|
await credentialsStore.remove(removeConfirm.platform.key)
|
||||||
|
removeConfirm.open = false
|
||||||
|
await credentialsStore.loadAll()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user