Files
kashilo/js/services/auth.js

336 lines
9.5 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 { setPersist, getPersist } from './directus/client.js'
import { cryptoService } from './crypto.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()
if (localStorage.getItem('dgray_remember') === '1') {
setPersist(true)
}
}
/**
* 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)
await cryptoService.unlock(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()
localStorage.removeItem('dgray_remember')
setPersist(false)
cryptoService.lock()
this.resetPreferencesToDefaults()
this.notifyListeners()
}
/**
* Clears all local data (tokens, keys, caches, preferences)
*/
async clearAllData() {
await this.logout()
cryptoService.destroyKeyPair()
const keysToRemove = []
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (key && key.startsWith('dgray_')) keysToRemove.push(key)
}
keysToRemove.forEach(k => localStorage.removeItem(k))
localStorage.removeItem('searchFilters')
localStorage.removeItem('locale')
localStorage.removeItem('theme')
for (let i = sessionStorage.length - 1; i >= 0; i--) {
const key = sessionStorage.key(i)
if (key && key.startsWith('dgray_')) sessionStorage.removeItem(key)
}
}
/**
* 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) {
const storage = getPersist() ? localStorage : sessionStorage
storage.setItem('dgray_uuid', uuid)
}
/**
* Gets stored UUID if available
* @returns {string|null}
*/
getStoredUuid() {
return sessionStorage.getItem('dgray_uuid') || localStorage.getItem('dgray_uuid')
}
/**
* Clears stored UUID
*/
clearStoredUuid() {
sessionStorage.removeItem('dgray_uuid')
localStorage.removeItem('dgray_uuid')
}
setRememberMe(value) {
setPersist(value)
if (value) {
localStorage.setItem('dgray_remember', '1')
} else {
localStorage.removeItem('dgray_remember')
}
}
getRememberMe() {
return localStorage.getItem('dgray_remember') === '1'
}
/**
* 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 {
const uuid = this.getStoredUuid()
if (uuid) await cryptoService.unlock(uuid)
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