feat: implement three-phase Amber login flow in AuthModal
- Updated Amber login button to reflect new three-phase process. - Added handling for reading public key and signature from clipboard. - Introduced new state management for Amber login phases. - Enhanced user feedback during each phase with appropriate messaging. - Refactored related functions for clarity and maintainability.
This commit is contained in:
@@ -86,7 +86,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Amber Login (NIP-55 Android Signer) -->
|
||||
<!-- Amber Login (NIP-55 Android Signer) — 3-phase flow -->
|
||||
<button
|
||||
v-if="amberPhase === 'idle'"
|
||||
@click="handleAmberOpen"
|
||||
@@ -99,12 +99,38 @@
|
||||
</svg>
|
||||
Sign in with Amber
|
||||
</button>
|
||||
<div v-else-if="amberPhase === 'waiting'" class="mt-3 space-y-3">
|
||||
|
||||
<!-- Phase 2: Read pubkey from clipboard -->
|
||||
<div v-else-if="amberPhase === 'waiting-pubkey'" class="mt-3 space-y-3">
|
||||
<p class="text-sm text-white/60 text-center">
|
||||
Approved in Amber? Tap below to complete sign-in.
|
||||
Approved in Amber? Tap below to continue.
|
||||
</p>
|
||||
<button
|
||||
@click="handleAmberComplete"
|
||||
@click="handleAmberPubkeyRead"
|
||||
:disabled="isLoading"
|
||||
class="amber-login-button amber-login-button--active w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg class="w-5 h-5 animate-pulse" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 2L3 7v6c0 5.25 3.75 10.15 9 11.25C17.25 23.15 21 18.25 21 13V7l-9-5z" fill="#F7931A" opacity="0.3" stroke="#F7931A" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<path d="M9 12l2 2 4-4" stroke="#F7931A" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Continue
|
||||
</button>
|
||||
<button
|
||||
@click="amberPhase = 'idle'; amberPubkey = null"
|
||||
class="w-full text-center text-sm text-white/40 hover:text-white/60 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Phase 3: Read signed NIP-98 event from clipboard -->
|
||||
<div v-else-if="amberPhase === 'waiting-signature'" class="mt-3 space-y-3">
|
||||
<p class="text-sm text-white/60 text-center">
|
||||
Approve the sign-in request in Amber, then tap below.
|
||||
</p>
|
||||
<button
|
||||
@click="handleAmberSignComplete"
|
||||
:disabled="isLoading"
|
||||
class="amber-login-button amber-login-button--active w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
@@ -115,7 +141,7 @@
|
||||
Complete Sign-in
|
||||
</button>
|
||||
<button
|
||||
@click="amberPhase = 'idle'"
|
||||
@click="amberPhase = 'idle'; amberPubkey = null"
|
||||
class="w-full text-center text-sm text-white/40 hover:text-white/60 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
@@ -203,6 +229,7 @@ import { ref, computed, watch } from 'vue'
|
||||
import { useAuth } from '../composables/useAuth'
|
||||
import { useAccounts } from '../composables/useAccounts'
|
||||
import { accountManager } from '../lib/accounts'
|
||||
import { authService } from '../services/auth.service'
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean
|
||||
@@ -227,8 +254,9 @@ const mode = ref<'login' | 'register' | 'forgot'>(props.defaultMode)
|
||||
const errorMessage = ref<string | null>(null)
|
||||
const isLoading = computed(() => authLoading.value || sovereignGenerating.value)
|
||||
|
||||
// Amber two-phase login state
|
||||
const amberPhase = ref<'idle' | 'waiting'>('idle')
|
||||
// Amber three-phase login state
|
||||
const amberPhase = ref<'idle' | 'waiting-pubkey' | 'waiting-signature'>('idle')
|
||||
const amberPubkey = ref<string | null>(null)
|
||||
|
||||
// ── Sovereign Identity Flow ──────────────────────────────────────
|
||||
type SovereignPhase = 'normal' | 'nah' | 'own-privacy' | 'generated'
|
||||
@@ -254,6 +282,7 @@ function closeModal() {
|
||||
emit('close')
|
||||
errorMessage.value = null
|
||||
amberPhase.value = 'idle'
|
||||
amberPubkey.value = null
|
||||
sovereignPhase.value = 'normal'
|
||||
sovereignDismissed.value = false
|
||||
generatedKeys.value = null
|
||||
@@ -396,7 +425,7 @@ async function handleNostrLogin() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Amber Login — Phase 1: Open Amber via Android intent.
|
||||
* Amber Login — Phase 1: Open Amber via Android intent to get the pubkey.
|
||||
*
|
||||
* Amber copies the pubkey to the clipboard. Mobile browsers block
|
||||
* automatic clipboard reads (navigator.clipboard.readText requires a
|
||||
@@ -413,25 +442,41 @@ function handleAmberOpen() {
|
||||
return
|
||||
}
|
||||
|
||||
// Open Amber via the nostrsigner intent
|
||||
// Open Amber via the nostrsigner intent to get pubkey
|
||||
const intent = 'intent:#Intent;scheme=nostrsigner;S.compressionType=none;S.returnType=signature;S.type=get_public_key;end'
|
||||
window.open(intent, '_blank')
|
||||
|
||||
// Switch to phase 2 — user will tap "Complete Sign-in" after Amber approves
|
||||
amberPhase.value = 'waiting'
|
||||
// Switch to phase 2 — user will tap "Continue" after Amber approves
|
||||
amberPhase.value = 'waiting-pubkey'
|
||||
}
|
||||
|
||||
/**
|
||||
* Amber Login — Phase 2: Read the pubkey from the clipboard.
|
||||
* Read a pubkey from clipboard text. Handles hex, npub, and nprofile formats.
|
||||
*/
|
||||
async function decodePubkeyFromClipboard(clipboardText: string): Promise<string> {
|
||||
if (/^[0-9a-f]{64}$/i.test(clipboardText)) {
|
||||
return clipboardText
|
||||
}
|
||||
if (clipboardText.startsWith('npub') || clipboardText.startsWith('nprofile')) {
|
||||
const { decodeProfilePointer } = await import('applesauce-core/helpers')
|
||||
const decoded = decodeProfilePointer(clipboardText)
|
||||
if (!decoded?.pubkey) throw new Error('Could not decode npub from clipboard')
|
||||
return decoded.pubkey
|
||||
}
|
||||
throw new Error('Clipboard does not contain a valid Nostr public key. Got: ' + clipboardText.slice(0, 20) + '...')
|
||||
}
|
||||
|
||||
/**
|
||||
* Amber Login — Phase 2: Read the pubkey from the clipboard, then
|
||||
* construct the NIP-98 event and open Amber again to sign it.
|
||||
*
|
||||
* This runs inside a click handler (user gesture) so
|
||||
* navigator.clipboard.readText() is permitted by the browser.
|
||||
*/
|
||||
async function handleAmberComplete() {
|
||||
async function handleAmberPubkeyRead() {
|
||||
errorMessage.value = null
|
||||
|
||||
try {
|
||||
// Read pubkey from clipboard (allowed because this is a user gesture)
|
||||
if (!navigator.clipboard?.readText) {
|
||||
throw new Error('Clipboard API not available')
|
||||
}
|
||||
@@ -441,55 +486,131 @@ async function handleAmberComplete() {
|
||||
throw new Error('Clipboard is empty. Please try again — make sure you approved the request in Amber.')
|
||||
}
|
||||
|
||||
// Decode the pubkey (Amber may return hex, npub, or nprofile)
|
||||
let pubkey: string
|
||||
if (/^[0-9a-f]{64}$/i.test(clipboardText)) {
|
||||
pubkey = clipboardText
|
||||
} else if (clipboardText.startsWith('npub') || clipboardText.startsWith('nprofile')) {
|
||||
// Decode using nostr-tools or applesauce helpers
|
||||
const { decodeProfilePointer } = await import('applesauce-core/helpers')
|
||||
const decoded = decodeProfilePointer(clipboardText)
|
||||
if (!decoded?.pubkey) throw new Error('Could not decode npub from clipboard')
|
||||
pubkey = decoded.pubkey
|
||||
} else {
|
||||
throw new Error('Clipboard does not contain a valid Nostr public key. Got: ' + clipboardText.slice(0, 20) + '...')
|
||||
}
|
||||
const pubkey = await decodePubkeyFromClipboard(clipboardText)
|
||||
amberPubkey.value = pubkey
|
||||
|
||||
// Register the Amber account in the account manager
|
||||
const { AmberClipboardSigner, AmberClipboardAccount, accountManager } = await import('../lib/accounts')
|
||||
// Register the Amber account in the account manager so the app
|
||||
// knows who is signing in (the signer is set but we won't call
|
||||
// signEvent on it — we use intents + clipboard for that)
|
||||
const { AmberClipboardSigner, AmberClipboardAccount } = await import('../lib/accounts')
|
||||
const signer = new AmberClipboardSigner()
|
||||
signer.pubkey = pubkey
|
||||
const account = new AmberClipboardAccount(pubkey, signer)
|
||||
accountManager.addAccount(account)
|
||||
accountManager.setActive(account)
|
||||
|
||||
// Create backend auth session
|
||||
await loginWithNostr(pubkey, 'amber-nip55', {
|
||||
// Build the unsigned NIP-98 event that the backend expects
|
||||
const sessionUrl = authService.getNostrSessionUrl()
|
||||
const unsignedEvent = {
|
||||
kind: 27235,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
['u', window.location.origin],
|
||||
['u', sessionUrl],
|
||||
['method', 'POST'],
|
||||
],
|
||||
content: '',
|
||||
pubkey,
|
||||
})
|
||||
}
|
||||
|
||||
// Open Amber to sign this NIP-98 event
|
||||
const eventJson = JSON.stringify(unsignedEvent)
|
||||
const intent = `intent:#Intent;scheme=nostrsigner;S.compressionType=none;S.returnType=event;S.type=sign_event;S.event=${encodeURIComponent(eventJson)};end`
|
||||
window.open(intent, '_blank')
|
||||
|
||||
// Move to phase 3 — user will tap "Complete Sign-in" after Amber signs
|
||||
amberPhase.value = 'waiting-signature'
|
||||
} catch (error: any) {
|
||||
console.error('Amber pubkey read failed:', error)
|
||||
handleAmberError(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Amber Login — Phase 3: Read the signed NIP-98 event from the
|
||||
* clipboard and complete authentication.
|
||||
*
|
||||
* This runs inside a click handler (user gesture) so
|
||||
* navigator.clipboard.readText() is permitted by the browser.
|
||||
*/
|
||||
async function handleAmberSignComplete() {
|
||||
errorMessage.value = null
|
||||
|
||||
try {
|
||||
if (!navigator.clipboard?.readText) {
|
||||
throw new Error('Clipboard API not available')
|
||||
}
|
||||
const clipboardText = (await navigator.clipboard.readText()).trim()
|
||||
|
||||
if (!clipboardText) {
|
||||
throw new Error('Clipboard is empty. Make sure you approved the signing request in Amber.')
|
||||
}
|
||||
|
||||
// Amber may return the full signed event JSON or just the signature hex
|
||||
let authHeader: string
|
||||
|
||||
if (clipboardText.startsWith('{')) {
|
||||
// Full signed event JSON — use directly
|
||||
const signedEvent = JSON.parse(clipboardText)
|
||||
if (!signedEvent.sig) {
|
||||
throw new Error('Signed event is missing the signature. Please try again.')
|
||||
}
|
||||
authHeader = `Nostr ${btoa(JSON.stringify(signedEvent))}`
|
||||
} else if (/^[0-9a-f]{128}$/i.test(clipboardText)) {
|
||||
// Just the signature hex — we need to reconstruct the full event
|
||||
// Build the same unsigned event we sent to Amber
|
||||
const pubkey = amberPubkey.value
|
||||
if (!pubkey) throw new Error('Lost track of the pubkey. Please start over.')
|
||||
|
||||
const sessionUrl = authService.getNostrSessionUrl()
|
||||
const event = {
|
||||
kind: 27235,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
['u', sessionUrl],
|
||||
['method', 'POST'],
|
||||
],
|
||||
content: '',
|
||||
pubkey,
|
||||
sig: clipboardText,
|
||||
}
|
||||
|
||||
// Calculate event ID using nostr-tools
|
||||
const { getEventHash } = await import('nostr-tools/pure')
|
||||
;(event as any).id = getEventHash(event as any)
|
||||
|
||||
authHeader = `Nostr ${btoa(JSON.stringify(event))}`
|
||||
} else {
|
||||
throw new Error('Clipboard does not contain a signed event. Got: ' + clipboardText.slice(0, 20) + '...')
|
||||
}
|
||||
|
||||
const pubkey = amberPubkey.value
|
||||
if (!pubkey) throw new Error('Lost track of the pubkey. Please start over.')
|
||||
|
||||
// Authenticate with the pre-signed NIP-98 header (bypasses signer.signEvent)
|
||||
await loginWithNostr(pubkey, 'amber-nip55', {}, authHeader)
|
||||
|
||||
amberPhase.value = 'idle'
|
||||
amberPubkey.value = null
|
||||
emit('success')
|
||||
closeModal()
|
||||
} catch (error: any) {
|
||||
console.error('Amber clipboard read failed:', error)
|
||||
console.error('Amber sign-in failed:', error)
|
||||
handleAmberError(error)
|
||||
}
|
||||
}
|
||||
|
||||
if (error.message?.includes('Clipboard is empty')) {
|
||||
errorMessage.value = 'Clipboard is empty. Open Amber, approve the request, then come back and tap "Complete Sign-in".'
|
||||
} else if (error.message?.includes('not available')) {
|
||||
errorMessage.value = 'Clipboard access is not available. Try using Chrome on Android.'
|
||||
} else if (error.message?.includes('permission')) {
|
||||
errorMessage.value = 'Clipboard permission denied. Please allow clipboard access and try again.'
|
||||
} else {
|
||||
errorMessage.value = error.message || 'Failed to read from Amber. Please try again.'
|
||||
}
|
||||
/**
|
||||
* Shared error handler for Amber flow phases.
|
||||
*/
|
||||
function handleAmberError(error: any) {
|
||||
if (error.message?.includes('Clipboard is empty')) {
|
||||
errorMessage.value = 'Clipboard is empty. Open Amber, approve the request, then come back and tap the button.'
|
||||
} else if (error.message?.includes('not available')) {
|
||||
errorMessage.value = 'Clipboard access is not available. Try using Chrome on Android.'
|
||||
} else if (error.message?.includes('permission')) {
|
||||
errorMessage.value = 'Clipboard permission denied. Please allow clipboard access and try again.'
|
||||
} else {
|
||||
errorMessage.value = error.message || 'Failed to read from Amber. Please try again.'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -312,6 +312,8 @@ async function fetchStream() {
|
||||
isLoadingStream.value = true
|
||||
streamError.value = null
|
||||
|
||||
let streamFile: string | null = null
|
||||
|
||||
try {
|
||||
// Try the backend stream endpoint (handles DRM, presigned URLs, etc.)
|
||||
const info = await indeehubApiService.getStreamingUrl(contentId)
|
||||
@@ -322,20 +324,15 @@ async function fetchStream() {
|
||||
if (errorMsg) {
|
||||
console.warn('[VideoPlayer] Backend returned error:', errorMsg)
|
||||
streamError.value = errorMsg
|
||||
return
|
||||
} else {
|
||||
const url = (info as any).file || info.url
|
||||
if (!url) {
|
||||
console.error('[VideoPlayer] No stream URL in response:', info)
|
||||
streamError.value = 'No video URL returned from server.'
|
||||
} else {
|
||||
streamFile = url
|
||||
}
|
||||
}
|
||||
|
||||
const streamFile = (info as any).file || info.url
|
||||
if (!streamFile) {
|
||||
console.error('[VideoPlayer] No stream URL in response:', info)
|
||||
streamError.value = 'No video URL returned from server.'
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[VideoPlayer] Playing URL:', streamFile)
|
||||
hlsStreamUrl.value = streamFile
|
||||
await nextTick()
|
||||
initPlayer(streamFile)
|
||||
} catch (err: any) {
|
||||
const status = err?.response?.status
|
||||
const responseData = err?.response?.data
|
||||
@@ -353,6 +350,15 @@ async function fetchStream() {
|
||||
} finally {
|
||||
isLoadingStream.value = false
|
||||
}
|
||||
|
||||
// Init the player AFTER isLoadingStream is false so the <video> element
|
||||
// is rendered in the DOM and videoEl ref is available.
|
||||
if (streamFile && !streamError.value) {
|
||||
console.log('[VideoPlayer] Playing URL:', streamFile)
|
||||
hlsStreamUrl.value = streamFile
|
||||
await nextTick()
|
||||
initPlayer(streamFile)
|
||||
}
|
||||
}
|
||||
|
||||
function initPlayer(url: string) {
|
||||
|
||||
@@ -21,8 +21,13 @@ export function useAuth() {
|
||||
return authStore.loginWithCognito(email, password)
|
||||
}
|
||||
|
||||
const loginWithNostr = async (pubkey: string, signature: string, event: any) => {
|
||||
return authStore.loginWithNostr(pubkey, signature, event)
|
||||
const loginWithNostr = async (
|
||||
pubkey: string,
|
||||
signature: string,
|
||||
event: any,
|
||||
preSignedAuthHeader?: string,
|
||||
) => {
|
||||
return authStore.loginWithNostr(pubkey, signature, event, preSignedAuthHeader)
|
||||
}
|
||||
|
||||
const register = async (email: string, password: string, legalName: string) => {
|
||||
|
||||
@@ -117,8 +117,16 @@ class AuthService {
|
||||
/**
|
||||
* Create Nostr session via NIP-98 HTTP Auth.
|
||||
* Signs a kind-27235 event and sends it as the Authorization header.
|
||||
*
|
||||
* @param _request Standard session request (pubkey, signature, event)
|
||||
* @param preSignedAuthHeader Optional pre-built `Nostr <base64>` header.
|
||||
* When provided the signer is NOT called — useful for Amber / clipboard
|
||||
* signers that cannot sign inline.
|
||||
*/
|
||||
async createNostrSession(_request: NostrSessionRequest): Promise<NostrSessionResponse> {
|
||||
async createNostrSession(
|
||||
_request: NostrSessionRequest,
|
||||
preSignedAuthHeader?: string,
|
||||
): Promise<NostrSessionResponse> {
|
||||
const relativeUrl = `${apiConfig.baseURL}/auth/nostr/session`
|
||||
const method = 'POST'
|
||||
|
||||
@@ -126,8 +134,8 @@ class AuthService {
|
||||
// what the backend reconstructs from Host / X-Forwarded-Proto.
|
||||
const absoluteUrl = new URL(relativeUrl, window.location.origin).toString()
|
||||
|
||||
// Create NIP-98 auth header — no body is sent
|
||||
const authHeader = await createNip98AuthHeader(absoluteUrl, method)
|
||||
// Use the pre-signed header if provided; otherwise sign inline
|
||||
const authHeader = preSignedAuthHeader ?? await createNip98AuthHeader(absoluteUrl, method)
|
||||
|
||||
// Send the request without a body.
|
||||
// We use axios({ method }) instead of axios.post(url, data) to
|
||||
@@ -156,6 +164,16 @@ class AuthService {
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the absolute URL for the Nostr session endpoint.
|
||||
* Exposed so callers (e.g. Amber flow) can construct the NIP-98 event
|
||||
* with the correct URL tag before sending it to an external signer.
|
||||
*/
|
||||
getNostrSessionUrl(): string {
|
||||
const relativeUrl = `${apiConfig.baseURL}/auth/nostr/session`
|
||||
return new URL(relativeUrl, window.location.origin).toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh Nostr session
|
||||
*/
|
||||
|
||||
@@ -243,9 +243,19 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Login with Nostr signature
|
||||
* Login with Nostr signature.
|
||||
*
|
||||
* @param preSignedAuthHeader Optional pre-built `Nostr <base64>` header.
|
||||
* When provided the active signer is NOT called for NIP-98 signing.
|
||||
* This is required for Amber / clipboard-based signers that cannot
|
||||
* sign events inline.
|
||||
*/
|
||||
async function loginWithNostr(pubkey: string, signature: string, event: any) {
|
||||
async function loginWithNostr(
|
||||
pubkey: string,
|
||||
signature: string,
|
||||
event: any,
|
||||
preSignedAuthHeader?: string,
|
||||
) {
|
||||
isLoading.value = true
|
||||
|
||||
// Mock Nostr login helper
|
||||
@@ -271,12 +281,12 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
|
||||
// Real API call — creates NIP-98 signed session via the active
|
||||
// Nostr account in accountManager (set by useAccounts before this call)
|
||||
const response = await authService.createNostrSession({
|
||||
pubkey,
|
||||
signature,
|
||||
event,
|
||||
})
|
||||
// Nostr account in accountManager (set by useAccounts before this call).
|
||||
// When preSignedAuthHeader is provided the signer is bypassed.
|
||||
const response = await authService.createNostrSession(
|
||||
{ pubkey, signature, event },
|
||||
preSignedAuthHeader,
|
||||
)
|
||||
|
||||
nostrPubkey.value = pubkey
|
||||
authType.value = 'nostr'
|
||||
|
||||
@@ -1,118 +1,148 @@
|
||||
<template>
|
||||
<div class="profile-view">
|
||||
<!-- Sticky Top Bar (backstage-style) -->
|
||||
<div class="page-top-bar-wrapper">
|
||||
<div class="page-top-bar-inner flex items-center gap-3">
|
||||
<h1 class="text-xl md:text-2xl font-bold text-white flex-shrink-0">Profile</h1>
|
||||
|
||||
<!-- Desktop Quick Actions -->
|
||||
<div class="hidden md:flex items-center gap-2 flex-1 justify-end">
|
||||
<button @click="$router.push('/library')" class="bar-tab">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
<span>My Library</span>
|
||||
</button>
|
||||
|
||||
<button @click="handleSourceToggle" class="bar-tab">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
||||
</svg>
|
||||
<span>{{ contentSourceLabel }}</span>
|
||||
</button>
|
||||
|
||||
<button @click="handleSignOut" class="bar-tab bar-tab-danger">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
<span>Sign Out</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="pt-24 pb-20 px-4">
|
||||
<div class="mx-auto max-w-4xl">
|
||||
<!-- Page Title -->
|
||||
<h1 class="text-4xl md:text-5xl font-bold text-white mb-8">Profile</h1>
|
||||
|
||||
<!-- User Info Section -->
|
||||
<section class="glass-card p-6 mb-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-6">Account Information</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-white/60 text-sm mb-1">Name</label>
|
||||
<div class="text-white font-medium">{{ user?.legalName }}</div>
|
||||
</div>
|
||||
<main class="page-main">
|
||||
<div class="mx-auto max-w-6xl">
|
||||
<!-- Desktop 2x2 Grid / Mobile single column -->
|
||||
<div class="profile-grid">
|
||||
<!-- Row 1, Col 1: Account Information -->
|
||||
<section class="glass-card p-6">
|
||||
<h2 class="text-xl font-bold text-white mb-5">Account Information</h2>
|
||||
|
||||
<div>
|
||||
<label class="block text-white/60 text-sm mb-1">Email</label>
|
||||
<div class="text-white font-medium">{{ user?.email }}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-white/60 text-sm mb-1">Member Since</label>
|
||||
<div class="text-white font-medium">{{ formatDate(user?.createdAt) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Subscription Section -->
|
||||
<section class="glass-card p-6 mb-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-6">Subscription</h2>
|
||||
|
||||
<div v-if="subscription" class="space-y-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<div class="text-lg font-bold text-white capitalize">{{ subscription.tier.replace('-', ' ') }}</div>
|
||||
<div class="text-white/60 text-sm">{{ subscription.status === 'active' ? 'Active' : 'Inactive' }}</div>
|
||||
<label class="block text-white/60 text-sm mb-1">Name</label>
|
||||
<div class="text-white font-medium">{{ user?.legalName }}</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-white font-medium">Renews on</div>
|
||||
<div class="text-white/60 text-sm">{{ formatDate(subscription.currentPeriodEnd) }}</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-white/60 text-sm mb-1">Email</label>
|
||||
<div class="text-white font-medium">{{ user?.email }}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-white/60 text-sm mb-1">Member Since</label>
|
||||
<div class="text-white font-medium">{{ formatDate(user?.createdAt) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Row 1, Col 2: Subscription -->
|
||||
<section class="glass-card p-6">
|
||||
<h2 class="text-xl font-bold text-white mb-5">Subscription</h2>
|
||||
|
||||
<div v-if="subscription" class="space-y-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<div class="text-lg font-bold text-white capitalize">{{ subscription.tier.replace('-', ' ') }}</div>
|
||||
<div class="text-white/60 text-sm">{{ subscription.status === 'active' ? 'Active' : 'Inactive' }}</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-white font-medium">Renews on</div>
|
||||
<div class="text-white/60 text-sm">{{ formatDate(subscription.currentPeriodEnd) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="subscription.status === 'active' && !subscription.cancelAtPeriodEnd"
|
||||
@click="handleCancelSubscription"
|
||||
class="hero-info-button w-full"
|
||||
>
|
||||
Cancel Subscription
|
||||
</button>
|
||||
|
||||
<div v-if="subscription.cancelAtPeriodEnd" class="text-orange-400 text-sm">
|
||||
Your subscription will end on {{ formatDate(subscription.currentPeriodEnd) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="subscription.status === 'active' && !subscription.cancelAtPeriodEnd"
|
||||
@click="handleCancelSubscription"
|
||||
class="hero-info-button w-full"
|
||||
>
|
||||
Cancel Subscription
|
||||
</button>
|
||||
|
||||
<div v-if="subscription.cancelAtPeriodEnd" class="text-orange-400 text-sm">
|
||||
Your subscription will end on {{ formatDate(subscription.currentPeriodEnd) }}
|
||||
<div v-else class="text-center py-6">
|
||||
<div class="text-white/60 mb-4">No active subscription</div>
|
||||
<button @click="$router.push('/')" class="hero-play-button">
|
||||
Browse Plans
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center py-6">
|
||||
<div class="text-white/60 mb-4">No active subscription</div>
|
||||
<button @click="$router.push('/')" class="hero-play-button">
|
||||
Browse Plans
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<!-- Nostr Section -->
|
||||
<section class="glass-card p-6 mb-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-6">Nostr Integration</h2>
|
||||
|
||||
<div v-if="user?.nostrPubkey" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-white/60 text-sm mb-1">Linked Nostr Public Key</label>
|
||||
<div class="text-white font-mono text-xs break-all">{{ user.nostrPubkey }}</div>
|
||||
<!-- Row 2, Col 1: Nostr Integration -->
|
||||
<section class="glass-card p-6">
|
||||
<h2 class="text-xl font-bold text-white mb-5">Nostr Integration</h2>
|
||||
|
||||
<div v-if="user?.nostrPubkey" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-white/60 text-sm mb-1">Linked Nostr Public Key</label>
|
||||
<div class="text-white font-mono text-xs break-all">{{ user.nostrPubkey }}</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="handleUnlinkNostr"
|
||||
class="hero-info-button"
|
||||
>
|
||||
Unlink Nostr Account
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="handleUnlinkNostr"
|
||||
class="hero-info-button"
|
||||
>
|
||||
Unlink Nostr Account
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center py-6">
|
||||
<div class="text-white/60 mb-4">Link your Nostr account to enable social features</div>
|
||||
<button @click="handleLinkNostr" class="hero-play-button">
|
||||
Link Nostr Account
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Filmmaker Section (if applicable) -->
|
||||
<section v-if="user?.filmmaker" class="glass-card p-6 mb-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-6">Filmmaker Dashboard</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-white/60 text-sm mb-1">Professional Name</label>
|
||||
<div class="text-white font-medium">{{ user.filmmaker.professionalName }}</div>
|
||||
<div v-else class="text-center py-6">
|
||||
<div class="text-white/60 mb-4">Link your Nostr account to enable social features</div>
|
||||
<button @click="handleLinkNostr" class="hero-play-button">
|
||||
Link Nostr Account
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button @click="$router.push('/backstage')" class="hero-play-button w-full">
|
||||
Go to Backstage
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<!-- Quick Actions (mirrors desktop profile dropdown) -->
|
||||
<section class="glass-card p-6 mb-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-6">Quick Actions</h2>
|
||||
<!-- Row 2, Col 2: Filmmaker Dashboard (if applicable) -->
|
||||
<section v-if="user?.filmmaker" class="glass-card p-6">
|
||||
<h2 class="text-xl font-bold text-white mb-5">Filmmaker Dashboard</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-white/60 text-sm mb-1">Professional Name</label>
|
||||
<div class="text-white font-medium">{{ user.filmmaker.professionalName }}</div>
|
||||
</div>
|
||||
|
||||
<button @click="$router.push('/backstage')" class="hero-play-button w-full">
|
||||
Go to Backstage
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Quick Actions (visible only on mobile) -->
|
||||
<section class="glass-card p-6 mt-4 md:hidden">
|
||||
<h2 class="text-xl font-bold text-white mb-5">Quick Actions</h2>
|
||||
|
||||
<div class="space-y-3">
|
||||
<!-- My Library -->
|
||||
<button @click="$router.push('/library')" class="profile-action-row">
|
||||
<svg class="w-5 h-5 text-white/70" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
@@ -123,7 +153,6 @@
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Content Source Toggle -->
|
||||
<button @click="handleSourceToggle" class="profile-action-row">
|
||||
<svg class="w-5 h-5 text-white/70" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
||||
@@ -132,7 +161,6 @@
|
||||
<span class="text-sm text-white/50">{{ contentSourceLabel }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Sign Out -->
|
||||
<div class="border-t border-white/10 my-1"></div>
|
||||
<button @click="handleSignOut" class="profile-action-row text-red-400">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -144,7 +172,6 @@
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -282,6 +309,82 @@ declare global {
|
||||
background: #0a0a0a;
|
||||
}
|
||||
|
||||
/* Sticky top bar - matches backstage pages */
|
||||
.page-top-bar-wrapper {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 40;
|
||||
padding: 112px 16px 8px 16px;
|
||||
background: linear-gradient(to bottom, #0a0a0a 90%, transparent);
|
||||
}
|
||||
|
||||
.page-top-bar-inner {
|
||||
padding: 12px 16px;
|
||||
border-radius: 16px;
|
||||
background: rgba(10, 10, 10, 0.9);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Bar tab buttons for quick actions */
|
||||
.bar-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border-radius: 12px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bar-tab:hover {
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.bar-tab-danger {
|
||||
color: rgba(248, 113, 113, 0.8);
|
||||
}
|
||||
|
||||
.bar-tab-danger:hover {
|
||||
color: rgba(248, 113, 113, 1);
|
||||
background: rgba(248, 113, 113, 0.1);
|
||||
}
|
||||
|
||||
/* Main content area */
|
||||
.page-main {
|
||||
padding: 8px 16px 96px 16px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.page-main {
|
||||
padding-bottom: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 2x2 grid layout for desktop */
|
||||
.profile-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.profile-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(24px);
|
||||
@@ -293,6 +396,7 @@ declare global {
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Mobile quick action rows */
|
||||
.profile-action-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user