286 lines
7.8 KiB
JavaScript
286 lines
7.8 KiB
JavaScript
/**
|
|
* Auth Service - UUID-based anonymous authentication
|
|
*
|
|
* No email addresses, no personal data
|
|
* User remembers only their UUID
|
|
* Email stored in Directus is hash(uuid)@domain - UUID cannot be recovered
|
|
*/
|
|
|
|
import { directus } from './directus.js'
|
|
import { i18n } from '../i18n.js'
|
|
|
|
const AUTH_DOMAIN = 'dgray.io'
|
|
|
|
class AuthService {
|
|
constructor() {
|
|
this.currentUser = null
|
|
this.listeners = new Set()
|
|
this.hashCache = new Map()
|
|
}
|
|
|
|
/**
|
|
* Generates a new UUID for account creation
|
|
* @returns {string} UUID v4
|
|
*/
|
|
generateUuid() {
|
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
return crypto.randomUUID()
|
|
}
|
|
|
|
const bytes = new Uint8Array(16)
|
|
crypto.getRandomValues(bytes)
|
|
bytes[6] = (bytes[6] & 0x0f) | 0x40
|
|
bytes[8] = (bytes[8] & 0x3f) | 0x80
|
|
return [...bytes].map((b, i) =>
|
|
([4, 6, 8, 10].includes(i) ? '-' : '') + b.toString(16).padStart(2, '0')
|
|
).join('')
|
|
}
|
|
|
|
/**
|
|
* Hashes a string using SHA-256
|
|
* @param {string} str - String to hash
|
|
* @returns {Promise<string>} Hex-encoded hash
|
|
*/
|
|
async hashString(str) {
|
|
if (this.hashCache.has(str)) {
|
|
return this.hashCache.get(str)
|
|
}
|
|
|
|
const encoder = new TextEncoder()
|
|
const data = encoder.encode(str)
|
|
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
|
|
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
|
const hash = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
|
|
|
|
this.hashCache.set(str, hash)
|
|
return hash
|
|
}
|
|
|
|
/**
|
|
* Converts UUID to hashed email for Directus
|
|
* The UUID cannot be recovered from the hash
|
|
* @param {string} uuid - User UUID
|
|
* @returns {Promise<string>} Hashed email address
|
|
*/
|
|
async uuidToEmail(uuid) {
|
|
const hash = await this.hashString(uuid)
|
|
return `${hash.substring(0, 32)}@${AUTH_DOMAIN}`
|
|
}
|
|
|
|
/**
|
|
* Creates a new anonymous account
|
|
* @returns {Promise<{uuid: string, success: boolean, error?: string}>}
|
|
*/
|
|
async createAccount() {
|
|
const uuid = this.generateUuid()
|
|
const email = await this.uuidToEmail(uuid)
|
|
|
|
try {
|
|
await directus.register(email, uuid)
|
|
|
|
const loginResult = await this.login(uuid)
|
|
|
|
if (!loginResult.success) {
|
|
return {
|
|
uuid,
|
|
success: true,
|
|
pendingVerification: true,
|
|
message: 'Account created. Login may require activation.'
|
|
}
|
|
}
|
|
|
|
return { uuid, success: true }
|
|
} catch (error) {
|
|
console.error('Registration failed:', error)
|
|
return {
|
|
uuid: null,
|
|
success: false,
|
|
error: error.message || 'Registration failed'
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Logs in with UUID
|
|
* @param {string} uuid - User UUID
|
|
* @returns {Promise<{success: boolean, error?: string}>}
|
|
*/
|
|
async login(uuid) {
|
|
const email = await this.uuidToEmail(uuid)
|
|
|
|
try {
|
|
await directus.login(email, uuid)
|
|
this.currentUser = await directus.getCurrentUser()
|
|
this.notifyListeners()
|
|
this.storeUuid(uuid)
|
|
|
|
return { success: true }
|
|
} catch (error) {
|
|
console.error('Login failed:', error)
|
|
return {
|
|
success: false,
|
|
error: error.message || 'Invalid UUID'
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Logs out the current user and resets preferences to defaults
|
|
*/
|
|
async logout() {
|
|
try {
|
|
await directus.logout()
|
|
} catch (e) {
|
|
// Ignore errors
|
|
}
|
|
|
|
this.currentUser = null
|
|
this.clearStoredUuid()
|
|
this.resetPreferencesToDefaults()
|
|
this.notifyListeners()
|
|
}
|
|
|
|
/**
|
|
* Reset preferences to defaults on logout
|
|
*/
|
|
resetPreferencesToDefaults() {
|
|
const defaultCurrency = 'USD'
|
|
const defaultLocale = 'en'
|
|
|
|
localStorage.setItem('dgray_currency', defaultCurrency)
|
|
localStorage.setItem('locale', defaultLocale)
|
|
|
|
window.dispatchEvent(new CustomEvent('currency-changed', { detail: { currency: defaultCurrency } }))
|
|
i18n.setLocale(defaultLocale)
|
|
}
|
|
|
|
/**
|
|
* Checks if user is logged in
|
|
* @returns {boolean}
|
|
*/
|
|
isLoggedIn() {
|
|
return directus.isAuthenticated()
|
|
}
|
|
|
|
/**
|
|
* Gets current user data
|
|
* @returns {Promise<Object|null>}
|
|
*/
|
|
async getUser() {
|
|
if (!this.isLoggedIn()) return null
|
|
|
|
if (!this.currentUser) {
|
|
try {
|
|
this.currentUser = await directus.getCurrentUser()
|
|
// Sync preferences to localStorage
|
|
this.syncPreferencesToLocal()
|
|
} catch (e) {
|
|
return null
|
|
}
|
|
}
|
|
|
|
return this.currentUser
|
|
}
|
|
|
|
/**
|
|
* Sync user preferences from Directus to localStorage and apply them
|
|
*/
|
|
syncPreferencesToLocal() {
|
|
if (!this.currentUser) return
|
|
|
|
if (this.currentUser.preferred_currency) {
|
|
const currency = this.currentUser.preferred_currency
|
|
localStorage.setItem('dgray_currency', currency)
|
|
window.dispatchEvent(new CustomEvent('currency-changed', { detail: { currency } }))
|
|
}
|
|
if (this.currentUser.preferred_locale) {
|
|
const locale = this.currentUser.preferred_locale
|
|
localStorage.setItem('locale', locale)
|
|
i18n.setLocale(locale)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update user preferences in Directus
|
|
* @param {Object} prefs - Preferences to update
|
|
* @param {string} [prefs.preferred_currency] - Currency code
|
|
* @param {string} [prefs.preferred_locale] - Locale code
|
|
* @returns {Promise<boolean>} Success
|
|
*/
|
|
async updatePreferences(prefs) {
|
|
if (!this.isLoggedIn()) return false
|
|
|
|
try {
|
|
await directus.updateCurrentUser(prefs)
|
|
// Update local cache
|
|
this.currentUser = { ...this.currentUser, ...prefs }
|
|
return true
|
|
} catch (e) {
|
|
console.error('Failed to update preferences:', e)
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stores UUID in localStorage (optional convenience)
|
|
* User should still backup their UUID
|
|
* @param {string} uuid
|
|
*/
|
|
storeUuid(uuid) {
|
|
localStorage.setItem('dgray_uuid', uuid)
|
|
}
|
|
|
|
/**
|
|
* Gets stored UUID if available
|
|
* @returns {string|null}
|
|
*/
|
|
getStoredUuid() {
|
|
return localStorage.getItem('dgray_uuid')
|
|
}
|
|
|
|
/**
|
|
* Clears stored UUID
|
|
*/
|
|
clearStoredUuid() {
|
|
localStorage.removeItem('dgray_uuid')
|
|
}
|
|
|
|
/**
|
|
* Subscribe to auth state changes
|
|
* @param {Function} callback
|
|
* @returns {Function} Unsubscribe function
|
|
*/
|
|
subscribe(callback) {
|
|
this.listeners.add(callback)
|
|
return () => this.listeners.delete(callback)
|
|
}
|
|
|
|
/**
|
|
* Notifies all listeners of auth state change
|
|
*/
|
|
notifyListeners() {
|
|
this.listeners.forEach(cb => cb(this.isLoggedIn(), this.currentUser))
|
|
}
|
|
|
|
/**
|
|
* Tries to restore session from stored tokens
|
|
*/
|
|
async tryRestoreSession() {
|
|
if (directus.isAuthenticated()) {
|
|
try {
|
|
this.currentUser = await directus.getCurrentUser()
|
|
this.syncPreferencesToLocal()
|
|
this.notifyListeners()
|
|
return true
|
|
} catch (e) {
|
|
directus.clearTokens()
|
|
return false
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
export const auth = new AuthService()
|
|
export default auth
|