diff --git a/frontend/src/components/items/ApplicationSettings.tsx b/frontend/src/components/items/ApplicationSettings.tsx index d0891a7..3d93396 100644 --- a/frontend/src/components/items/ApplicationSettings.tsx +++ b/frontend/src/components/items/ApplicationSettings.tsx @@ -1,7 +1,12 @@ -import { Check, Moon, Sun } from 'lucide-react'; +import { Check, Clock, Moon, Sun } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import toast from 'react-hot-toast'; import { useTheme } from '@/theme/ThemeProvider'; +import { settings } from '../../../wailsjs/go/models'; +import { GetSettings, UpdateSettings } from '../../../wailsjs/go/settings/Service'; + function ThemeSwatch({ active, dark, @@ -51,19 +56,94 @@ function ThemeSwatch({ ); } +const timeoutOptions = [ + { value: 1, label: '1 minute' }, + { value: 5, label: '5 minutes' }, + { value: 15, label: '15 minutes' }, + { value: 30, label: '30 minutes' }, + { value: 0, label: 'Disabled' }, +]; + export default function ApplicationSettings() { const { theme, setTheme } = useTheme(); const isDark = theme === 'dark'; + const [currentSettings, setCurrentSettings] = useState(null); + const [inactivityTimeout, setInactivityTimeout] = useState(15); + + useEffect(() => { + GetSettings() + .then((s: settings.Settings) => { + if (s) { + setCurrentSettings(s); + const timeout = (s as unknown as { InactivityTimeoutMinutes?: number }) + .InactivityTimeoutMinutes; + setInactivityTimeout(timeout ?? 15); + } + }) + .catch(() => {}); + }, []); + + const handleTimeoutChange = async (minutes: number) => { + try { + const input = new settings.UpdateSettingsInput({ + StorageMode: currentSettings?.StorageMode || 'local', + CloudKeys: currentSettings?.CloudKeys || [], + ErasureCoding: currentSettings?.ErasureCoding || false, + ErasureCodingConfig: currentSettings?.ErasureCodingConfig || '2+2', + InactivityTimeoutMinutes: minutes, + }); + + await UpdateSettings(input); + setInactivityTimeout(minutes); + toast.success('Inactivity timeout setting saved.'); + } catch { + toast.error('Failed to update inactivity timeout.'); + } + }; + return ( -
-
-

Appearance

-

Choose how ayo looks on your device.

+
+
+
+

Appearance

+

Choose how ayo looks on your device.

-
- setTheme('light')} /> - setTheme('dark')} /> +
+ setTheme('light')} /> + setTheme('dark')} /> +
+
+
+ +
+
+
+ +

Session Security & Auto-Lock

+
+

+ Automatically lock your signed-in session and close the database connection after a + period of inactivity. +

+ +
+ {timeoutOptions.map((opt) => ( + + ))} +
diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index bf82759..dcdd658 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -7,6 +7,7 @@ import { Register as RegisterService, ResetPassword as ResetPasswordService, SaveRecoveryKey as SaveRecoveryKeyService, + TouchSession, } from '../../wailsjs/go/auth/Service'; import { auth } from '../../wailsjs/go/models'; @@ -77,6 +78,52 @@ export function AuthProvider({ children }: { children: ReactNode }) { refreshSession(); }, []); + useEffect(() => { + if (!session) return; + + let hasActivity = false; + + const handleUserActivity = () => { + hasActivity = true; + }; + + window.addEventListener('mousemove', handleUserActivity, { passive: true }); + window.addEventListener('keydown', handleUserActivity, { passive: true }); + window.addEventListener('click', handleUserActivity, { passive: true }); + window.addEventListener('scroll', handleUserActivity, { passive: true }); + + // Touch session every 10s if user performed interaction + const activityInterval = setInterval(() => { + if (hasActivity) { + hasActivity = false; + if (typeof TouchSession === 'function') { + TouchSession().catch(() => {}); + } + } + }, 10000); + + // Check for expiration every 5s + const checkInterval = setInterval(async () => { + try { + const currentSession = await GetSession(); + if (!currentSession || currentSession.UserId === 0) { + setSession(null); + } + } catch { + setSession(null); + } + }, 5000); + + return () => { + window.removeEventListener('mousemove', handleUserActivity); + window.removeEventListener('keydown', handleUserActivity); + window.removeEventListener('click', handleUserActivity); + window.removeEventListener('scroll', handleUserActivity); + clearInterval(activityInterval); + clearInterval(checkInterval); + }; + }, [session]); + const login = async (input: auth.LoginInput) => { const success = await LoginService(input); if (success) { diff --git a/frontend/wailsjs/go/auth/Service.d.ts b/frontend/wailsjs/go/auth/Service.d.ts index 7ca2bb0..135e4cb 100755 --- a/frontend/wailsjs/go/auth/Service.d.ts +++ b/frontend/wailsjs/go/auth/Service.d.ts @@ -24,6 +24,10 @@ export function ResetPassword(arg1:auth.ResetPasswordInput):Promise; +export function SetInactivityTimeout(arg1:number):Promise; + export function SetMasterKeyStorage(arg1:string):Promise; export function Startup(arg1:context.Context):Promise; + +export function TouchSession():Promise; diff --git a/frontend/wailsjs/go/auth/Service.js b/frontend/wailsjs/go/auth/Service.js index 047b0fb..dcf952b 100755 --- a/frontend/wailsjs/go/auth/Service.js +++ b/frontend/wailsjs/go/auth/Service.js @@ -42,6 +42,10 @@ export function SaveRecoveryKey(arg1, arg2) { return window['go']['auth']['Service']['SaveRecoveryKey'](arg1, arg2); } +export function SetInactivityTimeout(arg1) { + return window['go']['auth']['Service']['SetInactivityTimeout'](arg1); +} + export function SetMasterKeyStorage(arg1) { return window['go']['auth']['Service']['SetMasterKeyStorage'](arg1); } @@ -49,3 +53,7 @@ export function SetMasterKeyStorage(arg1) { export function Startup(arg1) { return window['go']['auth']['Service']['Startup'](arg1); } + +export function TouchSession() { + return window['go']['auth']['Service']['TouchSession'](); +} diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 9b76766..84778c4 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -388,6 +388,7 @@ export namespace settings { CloudKeys: any[]; ErasureCoding: boolean; ErasureCodingConfig: string; + InactivityTimeoutMinutes: number; static createFrom(source: any = {}) { return new Settings(source); @@ -399,6 +400,7 @@ export namespace settings { this.CloudKeys = source["CloudKeys"]; this.ErasureCoding = source["ErasureCoding"]; this.ErasureCodingConfig = source["ErasureCodingConfig"]; + this.InactivityTimeoutMinutes = source["InactivityTimeoutMinutes"]; } } export class UpdateSettingsInput { @@ -406,6 +408,7 @@ export namespace settings { CloudKeys: any[]; ErasureCoding: boolean; ErasureCodingConfig: string; + InactivityTimeoutMinutes: number; static createFrom(source: any = {}) { return new UpdateSettingsInput(source); @@ -417,6 +420,7 @@ export namespace settings { this.CloudKeys = source["CloudKeys"]; this.ErasureCoding = source["ErasureCoding"]; this.ErasureCodingConfig = source["ErasureCodingConfig"]; + this.InactivityTimeoutMinutes = source["InactivityTimeoutMinutes"]; } } diff --git a/internal/features/auth/service.go b/internal/features/auth/service.go index 1d82170..15eb63f 100644 --- a/internal/features/auth/service.go +++ b/internal/features/auth/service.go @@ -5,6 +5,8 @@ import ( stderrors "errors" "os" "regexp" + "sync" + "time" dbclient "ayo/internal/clients/db" "ayo/internal/shared/crypto" @@ -31,9 +33,10 @@ import ( // password would leak it to the webview. The config lives on the Service // (unexported) and is only exposed in sanitized form via DatabaseConfig. type Session struct { - UserId int64 - Username string - masterKey []byte + UserId int64 + Username string + masterKey []byte + lastActiveAt time.Time } // MasterKey returns the session's decrypted 32-byte master key. It is how @@ -57,12 +60,14 @@ func (s *Session) MasterKey() []byte { // replaced with the vague *errors.InternalServerError so that no implementation // detail ever leaks to the UI. type Service struct { - ctx context.Context - conn *dbclient.Connection - repo Repository - session *Session - dbConfig dbclient.Config - validate *validator.Validate + ctx context.Context + conn *dbclient.Connection + repo Repository + session *Session + dbConfig dbclient.Config + validate *validator.Validate + mu sync.Mutex + inactivityTimeoutMinutes int } // Startup stores the Wails application context, which native dialogs (e.g. @@ -99,9 +104,10 @@ func NewService(conn *dbclient.Connection) *Service { _ = validate.RegisterValidation("password_strength", validatePasswordStrength) return &Service{ - conn: conn, - repo: NewRepository(conn), - validate: validate, + conn: conn, + repo: NewRepository(conn), + validate: validate, + inactivityTimeoutMinutes: 15, } } @@ -329,9 +335,10 @@ func (s *Service) Login(input LoginInput) (bool, error) { // session of the app s.session = &Session{ - UserId: user.ID, - Username: user.Username, - masterKey: masterKey, + UserId: user.ID, + Username: user.Username, + masterKey: masterKey, + lastActiveAt: time.Now(), } s.dbConfig = config @@ -502,16 +509,57 @@ func (s *Service) ResetPassword(input ResetPasswordInput) (*RegisterResult, erro return &RegisterResult{User: user, RecoveryKey: string(newRecoveryKey)}, nil } +// SetInactivityTimeout sets the session inactivity timeout in minutes (0 means disabled). +func (s *Service) SetInactivityTimeout(minutes int) { + s.mu.Lock() + defer s.mu.Unlock() + s.inactivityTimeoutMinutes = minutes +} + +// TouchSession updates the last active timestamp for the current session. +func (s *Service) TouchSession() { + s.mu.Lock() + defer s.mu.Unlock() + if s.session != nil && !s.checkSessionTimeoutLocked() { + s.session.lastActiveAt = time.Now() + } +} + +func (s *Service) checkSessionTimeoutLocked() bool { + if s.session == nil { + return false + } + if s.inactivityTimeoutMinutes > 0 && + time.Since(s.session.lastActiveAt) > time.Duration(s.inactivityTimeoutMinutes)*time.Minute { + s.logoutLocked() + return true + } + return false +} + +func (s *Service) logoutLocked() { + s.session = nil + s.dbConfig = dbclient.Config{} + if s.conn != nil { + s.conn.Close() + } +} + // Logout clears the in-memory session and closes the user's database // connection, ending the current user's access. func (s *Service) Logout() { - s.session = nil - s.dbConfig = dbclient.Config{} - s.conn.Close() + s.mu.Lock() + defer s.mu.Unlock() + s.logoutLocked() } -// GetSession returns the current in-memory session, or nil when signed out. +// GetSession returns the current in-memory session, or nil when signed out or expired. func (s *Service) GetSession() *Session { + s.mu.Lock() + defer s.mu.Unlock() + if s.checkSessionTimeoutLocked() { + return nil + } return s.session } @@ -519,7 +567,9 @@ func (s *Service) GetSession() *Session { // de-facto auth guard used by other services (e.g. settings) to gate access to // signed-in-only operations. func (s *Service) RequireSession() (*Session, error) { - if s.session == nil { + s.mu.Lock() + defer s.mu.Unlock() + if s.checkSessionTimeoutLocked() || s.session == nil { return nil, errors.ErrUnauthorized } return s.session, nil @@ -529,7 +579,9 @@ func (s *Service) RequireSession() (*Session, error) { // ErrUnauthorized when signed out. Other DB-backed services use it to resolve // the active client's dialect/connection when needed. func (s *Service) CurrentClient() (*dbclient.Client, error) { - if s.session == nil { + s.mu.Lock() + defer s.mu.Unlock() + if s.checkSessionTimeoutLocked() || s.session == nil { return nil, errors.ErrUnauthorized } return s.conn.Current() @@ -540,7 +592,9 @@ func (s *Service) CurrentClient() (*dbclient.Client, error) { // read-only database display. The password is stripped before returning so the // Wails-bound method can never leak it to the webview. func (s *Service) DatabaseConfig() (dbclient.Config, error) { - if s.session == nil { + s.mu.Lock() + defer s.mu.Unlock() + if s.checkSessionTimeoutLocked() || s.session == nil { return dbclient.Config{}, errors.ErrUnauthorized } config := s.dbConfig diff --git a/internal/features/settings/dto.go b/internal/features/settings/dto.go index 732ac4c..5b38bef 100644 --- a/internal/features/settings/dto.go +++ b/internal/features/settings/dto.go @@ -8,10 +8,11 @@ import ( // mirrors the Settings domain model so the wire format stays decoupled from the // stored entity, and its fields are validated by the service before use. type UpdateSettingsInput struct { - StorageMode StorageMode `validate:"required,oneof=local ayo"` - CloudKeys []CloudKey - ErasureCoding bool - ErasureCodingConfig ErasureCodingMode `validate:"omitempty,oneof=2+2 6+3 10+4 17+3"` + StorageMode StorageMode `validate:"required,oneof=local ayo"` + CloudKeys []CloudKey + ErasureCoding bool + ErasureCodingConfig ErasureCodingMode `validate:"omitempty,oneof=2+2 6+3 10+4 17+3"` + InactivityTimeoutMinutes int `validate:"gte=0,lte=1440"` } // UnmarshalJSON decodes CloudKeys into the concrete provider structs (AWSKey, @@ -19,7 +20,8 @@ type UpdateSettingsInput struct { func (i *UpdateSettingsInput) UnmarshalJSON(data []byte) error { type Alias UpdateSettingsInput aux := &struct { - CloudKeys []json.RawMessage `json:"CloudKeys"` + InactivityTimeoutMinutes *int `json:"InactivityTimeoutMinutes"` + CloudKeys []json.RawMessage `json:"CloudKeys"` *Alias }{ Alias: (*Alias)(i), @@ -34,5 +36,10 @@ func (i *UpdateSettingsInput) UnmarshalJSON(data []byte) error { return err } i.CloudKeys = keys + if aux.InactivityTimeoutMinutes != nil { + i.InactivityTimeoutMinutes = *aux.InactivityTimeoutMinutes + } else { + i.InactivityTimeoutMinutes = 15 + } return nil } diff --git a/internal/features/settings/model.go b/internal/features/settings/model.go index 548b372..b77b4db 100644 --- a/internal/features/settings/model.go +++ b/internal/features/settings/model.go @@ -28,17 +28,19 @@ const ( ) type Settings struct { - StorageMode StorageMode - CloudKeys []CloudKey - ErasureCoding bool - ErasureCodingConfig ErasureCodingMode + StorageMode StorageMode + CloudKeys []CloudKey + ErasureCoding bool + ErasureCodingConfig ErasureCodingMode + InactivityTimeoutMinutes int } // UnmarshalJSON reconstructs the polymorphic CloudKeys slice from raw JSON. func (s *Settings) UnmarshalJSON(data []byte) error { type Alias Settings aux := &struct { - CloudKeys []json.RawMessage `json:"CloudKeys"` + InactivityTimeoutMinutes *int `json:"InactivityTimeoutMinutes"` + CloudKeys []json.RawMessage `json:"CloudKeys"` *Alias }{ Alias: (*Alias)(s), @@ -53,5 +55,10 @@ func (s *Settings) UnmarshalJSON(data []byte) error { return err } s.CloudKeys = keys + if aux.InactivityTimeoutMinutes != nil { + s.InactivityTimeoutMinutes = *aux.InactivityTimeoutMinutes + } else { + s.InactivityTimeoutMinutes = 15 + } return nil } diff --git a/internal/features/settings/service.go b/internal/features/settings/service.go index 1554466..dee9c85 100644 --- a/internal/features/settings/service.go +++ b/internal/features/settings/service.go @@ -33,6 +33,11 @@ type ProviderValidator interface { Validate(key CloudKey) error } +// InactivityTimeoutSetter receives session inactivity timeout updates from settings. +type InactivityTimeoutSetter interface { + SetInactivityTimeout(minutes int) +} + // DatabaseInfo is the sanitized, read-only description of the signed-in user's // database. It deliberately excludes the database password. type DatabaseInfo struct { @@ -49,16 +54,18 @@ type Service struct { sessionProvider SessionProvider dbConfigProvider DatabaseConfigProvider providerValidator ProviderValidator + timeoutSetter InactivityTimeoutSetter repo Repository validate *validator.Validate } func NewService(sessionProvider SessionProvider, dbConfigProvider DatabaseConfigProvider, - providerValidator ProviderValidator, repo Repository) *Service { + providerValidator ProviderValidator, timeoutSetter InactivityTimeoutSetter, repo Repository) *Service { return &Service{ sessionProvider: sessionProvider, dbConfigProvider: dbConfigProvider, providerValidator: providerValidator, + timeoutSetter: timeoutSetter, repo: repo, validate: validator.New(), } @@ -91,7 +98,11 @@ func (s *Service) GetSettings() (*Settings, error) { } if len(data) == 0 { - return &Settings{}, nil + defaultSettings := &Settings{InactivityTimeoutMinutes: 15} + if s.timeoutSetter != nil { + s.timeoutSetter.SetInactivityTimeout(15) + } + return defaultSettings, nil } decryptedData, err := crypto.DecryptData(session.MasterKey(), data) @@ -103,6 +114,9 @@ func (s *Service) GetSettings() (*Settings, error) { if err := json.Unmarshal(decryptedData, &parsedSettings); err != nil { return nil, errors.AsInternalServerError("get settings: unmarshal", err) } + if s.timeoutSetter != nil { + s.timeoutSetter.SetInactivityTimeout(parsedSettings.InactivityTimeoutMinutes) + } return &parsedSettings, nil } @@ -161,5 +175,8 @@ func (s *Service) UpdateSettings(input UpdateSettingsInput) error { if err := s.repo.Save(session.Username, encryptedData); err != nil { return errors.AsInternalServerError("update settings: save", err) } + if s.timeoutSetter != nil { + s.timeoutSetter.SetInactivityTimeout(input.InactivityTimeoutMinutes) + } return nil } diff --git a/main.go b/main.go index 5022666..016d92a 100644 --- a/main.go +++ b/main.go @@ -73,7 +73,7 @@ func main() { // with the session master key. Provider configs are validated through the // storage package before saving. settingsRepository := settings.NewRepository() - settingsService := settings.NewService(authService, authService, storageValidator{}, settingsRepository) + settingsService := settings.NewService(authService, authService, storageValidator{}, authService, settingsRepository) // Queue service: persistent SQLite-backed job queue shared across features. // It resolves the signed-in user's database connection per operation.