diff --git a/package.json b/package.json index c7fa501..2796179 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ ] }, "private": true, - "packageManager": "pnpm@10.32.1", + "packageManager": "pnpm@11.4.0+sha512.f0febc7e37552ab485494a914241b338e0b3580b93d54ce31f00933015880863129038a1b4ae4e414a0ee63ac35bf21197e990172c4a68256450b5636310968f", "dependencies": { "@angular/common": "^21.2.4", "@angular/compiler": "^21.2.4", @@ -43,7 +43,7 @@ "@angular/router": "^21.2.4", "@ngrx/signals": "^21.0.1", "@primeuix/themes": "^2.0.3", - "@supabase/supabase-js": "^2.99.1", + "@supabase/supabase-js": "^2.106.2", "@tailwindcss/postcss": "^4.2.1", "chart.js": "^4.5.1", "dayjs": "^1.11.19", @@ -66,7 +66,6 @@ "eslint": "^10.0.3", "jsdom": "^27.1.0", "prettier": "^3.8.1", - "supabase": "^2.101.0", "typescript": "~5.9.2", "typescript-eslint": "8.56.1", "vitest": "^4.1.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d4da950..4daf3ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,8 +36,8 @@ importers: specifier: ^2.0.3 version: 2.0.3 '@supabase/supabase-js': - specifier: ^2.99.1 - version: 2.99.1 + specifier: ^2.106.2 + version: 2.106.2 '@tailwindcss/postcss': specifier: ^4.2.1 version: 4.2.1 @@ -99,9 +99,6 @@ importers: prettier: specifier: ^3.8.1 version: 3.8.1 - supabase: - specifier: ^2.101.0 - version: 2.101.0 typescript: specifier: ~5.9.2 version: 5.9.3 @@ -1811,72 +1808,31 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@supabase/auth-js@2.99.1': - resolution: {integrity: sha512-x7lKKTvKjABJt/FYcRSPiTT01Xhm2FF8RhfL8+RHMkmlwmRQ88/lREupIHKwFPW0W6pTCJqkZb7Yhpw/EZ+fNw==} + '@supabase/auth-js@2.106.2': + resolution: {integrity: sha512-VcAjUErkHkhC5Jaf+g/G1qbkQrFh8edaCdHa7pxJmHUjkWKjT7UnYCtPA89XV0N0GIYRkEqJZw5V62CtOxTmBQ==} engines: {node: '>=20.0.0'} - '@supabase/cli-darwin-arm64@2.101.0': - resolution: {integrity: sha512-SoWYzu2CIE+rADWsZH6H0YZYF8nV2YMelZLbjAqzahb5RRgFOTMWGhKCUj6g4KlNvocUnU19JObUJ7oCx/9aHg==} - cpu: [arm64] - os: [darwin] - - '@supabase/cli-darwin-x64@2.101.0': - resolution: {integrity: sha512-rPES03BF9KYkirq3X1Yll+T+eORueRvC858Yp2+2aQMvB4qxi6k+WI+ilCQ7NGzOJ3G0jl/ZG/KsSjZIa4AJ6g==} - cpu: [x64] - os: [darwin] - - '@supabase/cli-linux-arm64-musl@2.101.0': - resolution: {integrity: sha512-B5JFFKKpJLLMwn8vWUB7j5euZPytiDOLo9WYXp0mshg45OaFKjRcDImSzBzx4k0W8MK2x75rFEURca90Ic60Ug==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@supabase/cli-linux-arm64@2.101.0': - resolution: {integrity: sha512-E+hN1GRIFEi+U1Jf4Omw/gjwpSRTpJ5jai58tQana15AaXt3GCoG7z2YzWVnzD/mvAubTqA7SLHsV+A2lcdTGQ==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@supabase/cli-linux-x64-musl@2.101.0': - resolution: {integrity: sha512-pr9Ar1/aVaQVqjNSfwRgXHWJznQTIaxblGO+hKIl9k55Ajahiq8GConCwx6DjX1Wa9VW//5Dji+O0pkmFSfcHw==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@supabase/cli-linux-x64@2.101.0': - resolution: {integrity: sha512-7iU9uA+8pbmW5uz+yGxU0fM83a+8SH/gtKmSSRZrOu9vIGcugg9gLbN9GlWkG5P7I7wEjnoVKb7Jzea6onSeQA==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@supabase/cli-windows-arm64@2.101.0': - resolution: {integrity: sha512-mSnFDhTIAe9cr1iifv+DTQzpY/0WphywED2YT3yMGeu94jjxrXrreQkywxEaeSXTnc4ys1umtnWOB1CaBlYKag==} - cpu: [arm64] - os: [win32] - - '@supabase/cli-windows-x64@2.101.0': - resolution: {integrity: sha512-xzZpybVWq62VF102abZr1EP3BbA2bYecwYm3G2dJk+6ua7nAIodu1fiGHAvBSR0BLGQmesNb6KCneawNL3XBsQ==} - cpu: [x64] - os: [win32] - - '@supabase/functions-js@2.99.1': - resolution: {integrity: sha512-WQE62W5geYImCO4jzFxCk/avnK7JmOdtqu2eiPz3zOaNiIJajNRSAwMMDgEGd2EMs+sUVYj1LfBjfmW3EzHgIA==} + '@supabase/functions-js@2.106.2': + resolution: {integrity: sha512-oRnr0QrL8H+zTO1YyQ1QjiHZU/957jvubbxSJTUm2XLAgzoGGV9Tahfyd+uvLsBLRVmXLtpU3oyCjdQIvkGMOA==} engines: {node: '>=20.0.0'} - '@supabase/postgrest-js@2.99.1': - resolution: {integrity: sha512-gtw2ibJrADvfqrpUWXGNlrYUvxttF4WVWfPpTFKOb2IRj7B6YRWMDgcrYqIuD4ZEabK4m6YKQCCGy6clgf1lPA==} + '@supabase/phoenix@0.4.2': + resolution: {integrity: sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==} + + '@supabase/postgrest-js@2.106.2': + resolution: {integrity: sha512-tDOzyPgp9pIRMR2x6C9+uDSJrnXSzxLtt3d7nC+Lrsy3jnJDHYfdQC/xcRyhJE/TOBJ0heSqRKR3UmejDjZxsw==} engines: {node: '>=20.0.0'} - '@supabase/realtime-js@2.99.1': - resolution: {integrity: sha512-9EDdy/5wOseGFqxW88ShV9JMRhm7f+9JGY5x+LqT8c7R0X1CTLwg5qie8FiBWcXTZ+68yYxVWunI+7W4FhkWOg==} + '@supabase/realtime-js@2.106.2': + resolution: {integrity: sha512-LdRGT7DNhyZkPjubUv5bSdAZ0jSEX8wTHvx7htj7+K59TOZRvz4TuQK7tL2RWxyIZVeFMRluL04SzWS61rKnUA==} engines: {node: '>=20.0.0'} - '@supabase/storage-js@2.99.1': - resolution: {integrity: sha512-mf7zPfqofI62SOoyQJeNUVxe72E4rQsbWim6lTDPeLu3lHija/cP5utlQADGrjeTgOUN6znx/rWn7SjrETP1dw==} + '@supabase/storage-js@2.106.2': + resolution: {integrity: sha512-xgKCSYuev1YarV+iVqr+zlfgSyremnJtn8T0NCT8L4XmMv1CLtESc0Q6kNp8+mKWdX/8ND0nzm7OMKx08kwNAw==} engines: {node: '>=20.0.0'} - '@supabase/supabase-js@2.99.1': - resolution: {integrity: sha512-5MRoYD9ffXq8F6a036dm65YoSHisC3by/d22mauKE99Vrwf792KxYIIr/iqCX7E4hkuugbPZ5EGYHTB7MKy6Vg==} + '@supabase/supabase-js@2.106.2': + resolution: {integrity: sha512-2/RZ/1fmJx/MRSEDG2Xk8+J4JVk5clM9V0uSI6kUTrcS32KA89DtqI5RUOC9r6mzY3WBC9qexLjssIHjbLyVJA==} engines: {node: '>=20.0.0'} '@tailwindcss/node@4.2.1': @@ -1997,18 +1953,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/node@25.5.0': - resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} - '@types/node@25.5.2': resolution: {integrity: sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==} - '@types/phoenix@1.6.7': - resolution: {integrity: sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==} - - '@types/ws@8.18.1': - resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - '@typescript-eslint/eslint-plugin@8.56.1': resolution: {integrity: sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3571,10 +3518,6 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} - supabase@2.101.0: - resolution: {integrity: sha512-9wqlvXGeI+VjOMUKOGEpcZzTJGLia6IOv8LWN3W2u8F6Xiv4ZqAuTuXCp3MWfkhV5x9KQaBtN5d6IiLEMWzkJA==} - hasBin: true - symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} @@ -5599,67 +5542,37 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@supabase/auth-js@2.99.1': + '@supabase/auth-js@2.106.2': dependencies: tslib: 2.8.1 - '@supabase/cli-darwin-arm64@2.101.0': - optional: true - - '@supabase/cli-darwin-x64@2.101.0': - optional: true - - '@supabase/cli-linux-arm64-musl@2.101.0': - optional: true - - '@supabase/cli-linux-arm64@2.101.0': - optional: true - - '@supabase/cli-linux-x64-musl@2.101.0': - optional: true - - '@supabase/cli-linux-x64@2.101.0': - optional: true - - '@supabase/cli-windows-arm64@2.101.0': - optional: true - - '@supabase/cli-windows-x64@2.101.0': - optional: true - - '@supabase/functions-js@2.99.1': + '@supabase/functions-js@2.106.2': dependencies: tslib: 2.8.1 - '@supabase/postgrest-js@2.99.1': + '@supabase/phoenix@0.4.2': {} + + '@supabase/postgrest-js@2.106.2': dependencies: tslib: 2.8.1 - '@supabase/realtime-js@2.99.1': + '@supabase/realtime-js@2.106.2': dependencies: - '@types/phoenix': 1.6.7 - '@types/ws': 8.18.1 + '@supabase/phoenix': 0.4.2 tslib: 2.8.1 - ws: 8.19.0 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - '@supabase/storage-js@2.99.1': + '@supabase/storage-js@2.106.2': dependencies: iceberg-js: 0.8.1 tslib: 2.8.1 - '@supabase/supabase-js@2.99.1': + '@supabase/supabase-js@2.106.2': dependencies: - '@supabase/auth-js': 2.99.1 - '@supabase/functions-js': 2.99.1 - '@supabase/postgrest-js': 2.99.1 - '@supabase/realtime-js': 2.99.1 - '@supabase/storage-js': 2.99.1 - transitivePeerDependencies: - - bufferutil - - utf-8-validate + '@supabase/auth-js': 2.106.2 + '@supabase/functions-js': 2.106.2 + '@supabase/postgrest-js': 2.106.2 + '@supabase/realtime-js': 2.106.2 + '@supabase/storage-js': 2.106.2 '@tailwindcss/node@4.2.1': dependencies: @@ -5755,20 +5668,10 @@ snapshots: '@types/json-schema@7.0.15': {} - '@types/node@25.5.0': - dependencies: - undici-types: 7.18.2 - '@types/node@25.5.2': dependencies: undici-types: 7.18.2 - '@types/phoenix@1.6.7': {} - - '@types/ws@8.18.1': - dependencies: - '@types/node': 25.5.0 - '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -7555,17 +7458,6 @@ snapshots: strip-json-comments@3.1.1: {} - supabase@2.101.0: - optionalDependencies: - '@supabase/cli-darwin-arm64': 2.101.0 - '@supabase/cli-darwin-x64': 2.101.0 - '@supabase/cli-linux-arm64': 2.101.0 - '@supabase/cli-linux-arm64-musl': 2.101.0 - '@supabase/cli-linux-x64': 2.101.0 - '@supabase/cli-linux-x64-musl': 2.101.0 - '@supabase/cli-windows-arm64': 2.101.0 - '@supabase/cli-windows-x64': 2.101.0 - symbol-tree@3.2.4: {} tailwindcss-primeui@0.6.1(tailwindcss@4.3.0): diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index da776c8..c045222 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,10 @@ +allowBuilds: + '@firebase/util': true + '@parcel/watcher': true + esbuild: true + lmdb: true + msgpackr-extract: true + protobufjs: true onlyBuiltDependencies: - '@firebase/util' - '@parcel/watcher' diff --git a/src/app/app.config.ts b/src/app/app.config.ts index b026293..331b879 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -15,6 +15,7 @@ import { } from '@angular/router'; import { APP_CONFIG } from '@core/configs'; import { environment } from '@env'; +import { MessageService } from 'primeng/api'; import { providePrimeNG } from 'primeng/config'; import { routes } from './app.routes'; import { CustomPreset } from './custom-theme'; @@ -24,6 +25,7 @@ registerLocaleData(localeCs); export const appConfig: ApplicationConfig = { providers: [ + MessageService, provideBrowserGlobalErrorListeners(), provideRouter( routes, diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index b27ddf0..2e0a6d8 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -3,6 +3,7 @@ import { authGuard, guestGuard, hasAccountsGuard, + noDemoGuard, underDevelopmentGuard, } from '@core/guards'; import { PageNotFound } from './features/page-not-found'; @@ -35,11 +36,44 @@ export const routes: Routes = [ }, { path: 'login', - loadComponent: () => import('./features/login').then((c) => c.Login), + loadComponent: () => + import('./features/auth/login').then((c) => c.Login), + }, + { + path: 'signup', + loadComponent: () => + import('./features/auth/signup').then((c) => c.Signup), + }, + { + path: 'signin', + loadComponent: () => + import('./features/auth/signin-with-email').then( + (c) => c.SigninWithEmail, + ), + }, + { + path: 'reset-password', + loadComponent: () => + import('./features/auth/reset-password').then((c) => c.ResetPassword), }, { path: '**', redirectTo: 'login' }, ], }, + { + path: 'change-password', + canActivate: [authGuard, noDemoGuard], + loadComponent: () => + import('@core/layout/setup-layout').then((c) => c.SetupLayout), + children: [ + { + path: '**', + loadComponent: () => + import('./features/auth/change-password').then( + (c) => c.ChangePassword, + ), + }, + ], + }, { path: '', canActivate: [authGuard], @@ -85,6 +119,12 @@ export const routes: Routes = [ loadComponent: () => import('./features/analytics').then((c) => c.Analytics), }, + { + path: 'settings', + canActivate: [noDemoGuard], + loadComponent: () => + import('./features/settings').then((c) => c.Settings), + }, { path: '**', redirectTo: 'dashboard' }, ], }, diff --git a/src/app/core/guards/index.ts b/src/app/core/guards/index.ts index 2e3e02e..01af0eb 100644 --- a/src/app/core/guards/index.ts +++ b/src/app/core/guards/index.ts @@ -1,4 +1,5 @@ export * from './auth-guard'; export * from './guest-guard'; export * from './has-accounts-guard'; +export * from './no-demo-guard'; export * from './under-development-guard'; diff --git a/src/app/core/guards/no-demo-guard.spec.ts b/src/app/core/guards/no-demo-guard.spec.ts new file mode 100644 index 0000000..0811e43 --- /dev/null +++ b/src/app/core/guards/no-demo-guard.spec.ts @@ -0,0 +1,17 @@ +import { TestBed } from '@angular/core/testing'; +import { CanActivateFn } from '@angular/router'; + +import { noDemoGuard } from './no-demo-guard'; + +describe('noDemoGuard', () => { + const executeGuard: CanActivateFn = (...guardParameters) => + TestBed.runInInjectionContext(() => noDemoGuard(...guardParameters)); + + beforeEach(() => { + TestBed.configureTestingModule({}); + }); + + it('should be created', () => { + expect(executeGuard).toBeTruthy(); + }); +}); diff --git a/src/app/core/guards/no-demo-guard.ts b/src/app/core/guards/no-demo-guard.ts new file mode 100644 index 0000000..7932ebf --- /dev/null +++ b/src/app/core/guards/no-demo-guard.ts @@ -0,0 +1,14 @@ +import { inject } from '@angular/core'; +import { CanActivateFn, RedirectCommand, Router } from '@angular/router'; +import { DemoModeService } from '@core/services/demo-mode/demo-mode.service'; + +export const noDemoGuard: CanActivateFn = () => { + const router = inject(Router); + const demoModeService = inject(DemoModeService); + + if (demoModeService.isDemo()) { + return new RedirectCommand(router.parseUrl('/dashboard')); + } + + return true; +}; diff --git a/src/app/core/i18n/change-password.translations.ts b/src/app/core/i18n/change-password.translations.ts new file mode 100644 index 0000000..15a7bff --- /dev/null +++ b/src/app/core/i18n/change-password.translations.ts @@ -0,0 +1,44 @@ +export const CHANGE_PASSWORD_TRANSLATIONS = { + en: { + title: 'Change Password', + subtitle: 'Update your account password', + newPassword: 'New Password', + newPasswordPlaceholder: 'Enter new password', + confirmPassword: 'Confirm New Password', + confirmPlaceholder: 'Repeat new password', + submit: 'Update Password', + newPasswordRequired: 'New password is required', + newPasswordTooShort: 'Password must be at least 8 characters', + confirmRequired: 'Please confirm your new password', + passwordsMismatch: 'Passwords do not match', + successMessage: 'Password updated successfully.', + }, + uk: { + title: 'Зміна пароля', + subtitle: 'Оновіть пароль вашого облікового запису', + newPassword: 'Новий пароль', + newPasswordPlaceholder: 'Введіть новий пароль', + confirmPassword: 'Підтвердьте новий пароль', + confirmPlaceholder: 'Повторіть новий пароль', + submit: 'Оновити пароль', + newPasswordRequired: "Новий пароль є обов'язковим", + newPasswordTooShort: 'Пароль має містити щонайменше 8 символів', + confirmRequired: 'Будь ласка, підтвердьте новий пароль', + passwordsMismatch: 'Паролі не збігаються', + successMessage: 'Пароль успішно оновлено.', + }, + cs: { + title: 'Změna hesla', + subtitle: 'Aktualizujte heslo svého účtu', + newPassword: 'Nové heslo', + newPasswordPlaceholder: 'Zadejte nové heslo', + confirmPassword: 'Potvrďte nové heslo', + confirmPlaceholder: 'Zopakujte nové heslo', + submit: 'Aktualizovat heslo', + newPasswordRequired: 'Nové heslo je povinné', + newPasswordTooShort: 'Heslo musí mít alespoň 8 znaků', + confirmRequired: 'Potvrďte prosím nové heslo', + passwordsMismatch: 'Hesla se neshodují', + successMessage: 'Heslo bylo úspěšně aktualizováno.', + }, +}; diff --git a/src/app/core/i18n/core.translations.ts b/src/app/core/i18n/core.translations.ts index bcedb38..4af8a5b 100644 --- a/src/app/core/i18n/core.translations.ts +++ b/src/app/core/i18n/core.translations.ts @@ -20,6 +20,8 @@ export const CORE_TRANSLATIONS = { analytics: { title: 'Analytics', description: 'Analyze your financial data' }, }, logout: 'Log out', + changePassword: 'Change Password', + settings: 'Settings', language: 'Language', }, uk: { @@ -43,6 +45,8 @@ export const CORE_TRANSLATIONS = { analytics: { title: 'Аналітика', description: 'Аналіз фінансових даних' }, }, logout: 'Вийти', + changePassword: 'Змінити пароль', + settings: 'Налаштування', language: 'Мова', }, cs: { @@ -66,6 +70,8 @@ export const CORE_TRANSLATIONS = { analytics: { title: 'Analytika', description: 'Analýza finančních dat' }, }, logout: 'Odhlásit se', + changePassword: 'Změnit heslo', + settings: 'Nastavení', language: 'Jazyk', }, }; diff --git a/src/app/core/i18n/index.ts b/src/app/core/i18n/index.ts index ea7f7d8..415cc67 100644 --- a/src/app/core/i18n/index.ts +++ b/src/app/core/i18n/index.ts @@ -1,4 +1,5 @@ import { ANALYTICS_TRANSLATIONS } from './analytics.translations'; +import { CHANGE_PASSWORD_TRANSLATIONS } from './change-password.translations'; import { BUDGET_TRANSLATIONS } from './budget.translations'; import { CORE_TRANSLATIONS } from './core.translations'; import { DASHBOARD_TRANSLATIONS } from './dashboard.translations'; @@ -8,6 +9,10 @@ import { LANDING_TRANSLATIONS } from './landing.translations'; import { LOGIN_TRANSLATIONS } from './login.translations'; import { MISC_TRANSLATIONS } from './misc.translations'; import { SHARED_TRANSLATIONS } from './shared.translations'; +import { RESET_PASSWORD_TRANSLATIONS } from './reset-password.translations'; +import { SETTINGS_TRANSLATIONS } from './settings.translations'; +import { SIGNIN_TRANSLATIONS } from './signin.translations'; +import { SIGNUP_TRANSLATIONS } from './signup.translations'; import { TRANSACTIONS_TRANSLATIONS } from './transactions.translations'; import { WALLET_TRANSLATIONS } from './wallet.translations'; @@ -19,6 +24,10 @@ export const TRANSLATIONS = { core: CORE_TRANSLATIONS.en, landing: LANDING_TRANSLATIONS.en, login: LOGIN_TRANSLATIONS.en, + signup: SIGNUP_TRANSLATIONS.en, + signin: SIGNIN_TRANSLATIONS.en, + resetPassword: RESET_PASSWORD_TRANSLATIONS.en, + changePassword: CHANGE_PASSWORD_TRANSLATIONS.en, dashboard: DASHBOARD_TRANSLATIONS.en, transactions: TRANSACTIONS_TRANSLATIONS.en, wallet: WALLET_TRANSLATIONS.en, @@ -26,6 +35,7 @@ export const TRANSLATIONS = { goals: GOALS_TRANSLATIONS.en, analytics: ANALYTICS_TRANSLATIONS.en, groups: GROUPS_TRANSLATIONS.en, + settings: SETTINGS_TRANSLATIONS.en, misc: MISC_TRANSLATIONS.en, }, uk: { @@ -33,6 +43,10 @@ export const TRANSLATIONS = { core: CORE_TRANSLATIONS.uk, landing: LANDING_TRANSLATIONS.uk, login: LOGIN_TRANSLATIONS.uk, + signup: SIGNUP_TRANSLATIONS.uk, + signin: SIGNIN_TRANSLATIONS.uk, + resetPassword: RESET_PASSWORD_TRANSLATIONS.uk, + changePassword: CHANGE_PASSWORD_TRANSLATIONS.uk, dashboard: DASHBOARD_TRANSLATIONS.uk, transactions: TRANSACTIONS_TRANSLATIONS.uk, wallet: WALLET_TRANSLATIONS.uk, @@ -40,6 +54,7 @@ export const TRANSLATIONS = { goals: GOALS_TRANSLATIONS.uk, analytics: ANALYTICS_TRANSLATIONS.uk, groups: GROUPS_TRANSLATIONS.uk, + settings: SETTINGS_TRANSLATIONS.uk, misc: MISC_TRANSLATIONS.uk, }, cs: { @@ -47,6 +62,10 @@ export const TRANSLATIONS = { core: CORE_TRANSLATIONS.cs, landing: LANDING_TRANSLATIONS.cs, login: LOGIN_TRANSLATIONS.cs, + signup: SIGNUP_TRANSLATIONS.cs, + signin: SIGNIN_TRANSLATIONS.cs, + resetPassword: RESET_PASSWORD_TRANSLATIONS.cs, + changePassword: CHANGE_PASSWORD_TRANSLATIONS.cs, dashboard: DASHBOARD_TRANSLATIONS.cs, transactions: TRANSACTIONS_TRANSLATIONS.cs, wallet: WALLET_TRANSLATIONS.cs, @@ -54,6 +73,7 @@ export const TRANSLATIONS = { goals: GOALS_TRANSLATIONS.cs, analytics: ANALYTICS_TRANSLATIONS.cs, groups: GROUPS_TRANSLATIONS.cs, + settings: SETTINGS_TRANSLATIONS.cs, misc: MISC_TRANSLATIONS.cs, }, } satisfies Record; diff --git a/src/app/core/i18n/login.translations.ts b/src/app/core/i18n/login.translations.ts index 9f8a28d..87a8d3f 100644 --- a/src/app/core/i18n/login.translations.ts +++ b/src/app/core/i18n/login.translations.ts @@ -1,23 +1,38 @@ export const LOGIN_TRANSLATIONS = { en: { title: 'Sign In to the App', - subtitle: 'Control your money with confidence. Make your expenses answer to you.', + subtitle: + 'Control your money with confidence. Make your expenses answer to you.', badge: 'Free and secure', continueWithGoogle: 'Continue with Google', tryDemo: 'Try Demo Mode', + signInWithEmail: 'Sign in with email', + signInWithPasskey: 'Sign in with passkey', + noAccount: "Don't have an account?", + signUpLink: 'Sign up', }, uk: { title: 'Вхід до застосунку', - subtitle: 'Контролюйте свої гроші впевнено. Нехай витрати вам підкоряються.', + subtitle: + 'Контролюйте свої гроші впевнено. Нехай витрати вам підкоряються.', badge: 'Безкоштовно та безпечно', continueWithGoogle: 'Продовжити з Google', tryDemo: 'Демо-режим', + signInWithEmail: 'Увійти через email', + signInWithPasskey: 'Увійти через ключ доступу', + noAccount: 'Ще не маєте облікового запису?', + signUpLink: 'Зареєструватися', }, cs: { title: 'Přihlásit se do aplikace', - subtitle: 'Ovládejte své peníze s jistotou. Nechte výdaje odpovídat za sebe.', + subtitle: + 'Ovládejte své peníze s jistotou. Nechte výdaje odpovídat za sebe.', badge: 'Zdarma a bezpečně', continueWithGoogle: 'Pokračovat přes Google', tryDemo: 'Vyzkoušet Demo', + signInWithEmail: 'Přihlásit se e-mailem', + signInWithPasskey: 'Přihlásit se přístupovým klíčem', + noAccount: 'Nemáte účet?', + signUpLink: 'Registrovat se', }, }; diff --git a/src/app/core/i18n/reset-password.translations.ts b/src/app/core/i18n/reset-password.translations.ts new file mode 100644 index 0000000..2ed7921 --- /dev/null +++ b/src/app/core/i18n/reset-password.translations.ts @@ -0,0 +1,38 @@ +export const RESET_PASSWORD_TRANSLATIONS = { + en: { + title: 'Reset Password', + subtitle: "Enter your email and we'll send you a reset link.", + email: 'Email', + emailPlaceholder: 'Enter your email', + submit: 'Send Reset Link', + emailRequired: 'Email is required', + emailInvalid: 'Enter a valid email', + successMessage: 'Check your inbox — we sent you a reset link.', + rememberPassword: 'Remembered your password?', + signInLink: 'Sign in', + }, + uk: { + title: 'Скидання пароля', + subtitle: 'Введіть email, і ми надішлемо вам посилання для скидання.', + email: 'Email', + emailPlaceholder: 'Введіть ваш email', + submit: 'Надіслати посилання', + emailRequired: "Email є обов'язковим", + emailInvalid: 'Введіть коректний email', + successMessage: 'Перевірте пошту — ми надіслали вам посилання для скидання.', + rememberPassword: 'Згадали пароль?', + signInLink: 'Увійти', + }, + cs: { + title: 'Obnovení hesla', + subtitle: 'Zadejte svůj e-mail a pošleme vám odkaz pro obnovení.', + email: 'E-mail', + emailPlaceholder: 'Zadejte svůj e-mail', + submit: 'Odeslat odkaz', + emailRequired: 'E-mail je povinný', + emailInvalid: 'Zadejte platný e-mail', + successMessage: 'Zkontrolujte doručenou poštu — poslali jsme vám odkaz pro obnovení.', + rememberPassword: 'Vzpomněli jste si na heslo?', + signInLink: 'Přihlásit se', + }, +}; diff --git a/src/app/core/i18n/settings.translations.ts b/src/app/core/i18n/settings.translations.ts new file mode 100644 index 0000000..7fcffb4 --- /dev/null +++ b/src/app/core/i18n/settings.translations.ts @@ -0,0 +1,127 @@ +export const SETTINGS_TRANSLATIONS = { + en: { + title: 'Settings', + sections: { + password: 'Password', + passkeys: 'Passkeys', + }, + updatePassword: { + title: 'Update password', + subtitle: 'Change the password for your account', + }, + passkeys: { + title: 'Passkeys', + subtitle: 'Sign in without a password using a passkey on this device', + add: 'Add passkey', + empty: 'You have no passkeys yet.', + unnamed: 'Unnamed passkey', + added: 'Added', + lastUsed: 'last used', + rename: 'Rename', + delete: 'Delete', + addSuccess: 'Passkey added successfully.', + addError: 'Add Passkey Error', + renameSuccess: 'Passkey renamed successfully.', + renameError: 'Rename Passkey Error', + deleteSuccess: 'Passkey deleted successfully.', + deleteError: 'Delete Passkey Error', + loadError: 'Load Passkeys Error', + renameDialog: { + header: 'Rename passkey', + label: 'Name', + required: 'Name is required', + }, + deleteConfirm: { + header: 'Delete passkey', + message: + 'Delete this passkey? You will no longer be able to sign in with it.', + accept: 'Yes, delete', + reject: 'Keep it', + }, + }, + }, + uk: { + title: 'Налаштування', + sections: { + password: 'Пароль', + passkeys: 'Ключі доступу', + }, + updatePassword: { + title: 'Оновити пароль', + subtitle: 'Змініть пароль вашого облікового запису', + }, + passkeys: { + title: 'Ключі доступу', + subtitle: + 'Входьте без пароля за допомогою ключа доступу на цьому пристрої', + add: 'Додати ключ доступу', + empty: 'У вас ще немає ключів доступу.', + unnamed: 'Ключ без назви', + added: 'Додано', + lastUsed: 'востаннє використано', + rename: 'Перейменувати', + delete: 'Видалити', + addSuccess: 'Ключ доступу успішно додано.', + addError: 'Помилка додавання ключа доступу', + renameSuccess: 'Ключ доступу успішно перейменовано.', + renameError: 'Помилка перейменування ключа доступу', + deleteSuccess: 'Ключ доступу успішно видалено.', + deleteError: 'Помилка видалення ключа доступу', + loadError: 'Помилка завантаження ключів доступу', + renameDialog: { + header: 'Перейменувати ключ доступу', + label: 'Назва', + required: "Назва обов'язкова", + }, + deleteConfirm: { + header: 'Видалити ключ доступу', + message: + 'Видалити цей ключ доступу? Ви більше не зможете входити за його допомогою.', + accept: 'Так, видалити', + reject: 'Залишити', + }, + }, + }, + cs: { + title: 'Nastavení', + sections: { + password: 'Heslo', + passkeys: 'Přístupové klíče', + }, + updatePassword: { + title: 'Aktualizovat heslo', + subtitle: 'Změňte heslo svého účtu', + }, + passkeys: { + title: 'Přístupové klíče', + subtitle: + 'Přihlaste se bez hesla pomocí přístupového klíče v tomto zařízení', + add: 'Přidat přístupový klíč', + empty: 'Zatím nemáte žádné přístupové klíče.', + unnamed: 'Nepojmenovaný klíč', + added: 'Přidáno', + lastUsed: 'naposledy použito', + rename: 'Přejmenovat', + delete: 'Smazat', + addSuccess: 'Přístupový klíč byl úspěšně přidán.', + addError: 'Chyba při přidávání přístupového klíče', + renameSuccess: 'Přístupový klíč byl úspěšně přejmenován.', + renameError: 'Chyba při přejmenování přístupového klíče', + deleteSuccess: 'Přístupový klíč byl úspěšně smazán.', + deleteError: 'Chyba při mazání přístupového klíče', + loadError: 'Chyba při načítání přístupových klíčů', + renameDialog: { + header: 'Přejmenovat přístupový klíč', + label: 'Název', + required: 'Název je povinný', + }, + deleteConfirm: { + header: 'Smazat přístupový klíč', + message: + 'Smazat tento přístupový klíč? Už se s ním nebudete moci přihlásit.', + accept: 'Ano, smazat', + reject: 'Ponechat', + }, + }, + }, +}; diff --git a/src/app/core/i18n/signin.translations.ts b/src/app/core/i18n/signin.translations.ts new file mode 100644 index 0000000..a4d8532 --- /dev/null +++ b/src/app/core/i18n/signin.translations.ts @@ -0,0 +1,50 @@ +export const SIGNIN_TRANSLATIONS = { + en: { + title: 'Sign In with Email', + subtitle: 'Welcome back. Enter your credentials to continue.', + email: 'Email', + emailPlaceholder: 'Enter your email', + password: 'Password', + passwordPlaceholder: 'Enter your password', + submit: 'Sign In', + emailRequired: 'Email is required', + emailInvalid: 'Enter a valid email', + passwordRequired: 'Password is required', + invalidCredentials: 'Invalid login or password', + forgotPassword: 'Forgot password?', + noAccount: "Don't have an account?", + signUpLink: 'Sign up', + }, + uk: { + title: 'Вхід через email', + subtitle: 'З поверненням. Введіть свої дані для входу.', + email: 'Email', + emailPlaceholder: 'Введіть ваш email', + password: 'Пароль', + passwordPlaceholder: 'Введіть пароль', + submit: 'Увійти', + emailRequired: "Email є обов'язковим", + emailInvalid: 'Введіть коректний email', + passwordRequired: "Пароль є обов'язковим", + invalidCredentials: 'Невірний логін або пароль', + forgotPassword: 'Забули пароль?', + noAccount: 'Ще не маєте облікового запису?', + signUpLink: 'Зареєструватися', + }, + cs: { + title: 'Přihlášení e-mailem', + subtitle: 'Vítejte zpět. Zadejte své přihlašovací údaje.', + email: 'E-mail', + emailPlaceholder: 'Zadejte svůj e-mail', + password: 'Heslo', + passwordPlaceholder: 'Zadejte své heslo', + submit: 'Přihlásit se', + emailRequired: 'E-mail je povinný', + emailInvalid: 'Zadejte platný e-mail', + passwordRequired: 'Heslo je povinné', + invalidCredentials: 'Neplatné přihlašovací údaje', + forgotPassword: 'Zapomněli jste heslo?', + noAccount: 'Nemáte účet?', + signUpLink: 'Registrovat se', + }, +}; diff --git a/src/app/core/i18n/signup.translations.ts b/src/app/core/i18n/signup.translations.ts new file mode 100644 index 0000000..614465e --- /dev/null +++ b/src/app/core/i18n/signup.translations.ts @@ -0,0 +1,68 @@ +export const SIGNUP_TRANSLATIONS = { + en: { + title: 'Register new user', + subtitle: 'Sign up with your email to start tracking your finances.', + email: 'Email', + emailPlaceholder: 'Enter your email', + password: 'Password', + passwordPlaceholder: 'Create a password', + confirmPassword: 'Confirm Password', + confirmPlaceholder: 'Repeat your password', + submit: 'Sign Up', + emailRequired: 'Email is required', + emailInvalid: 'Enter a valid email', + userExists: 'User already exists', + passwordRequired: 'Password is required', + passwordTooShort: 'Password must be at least 8 characters', + confirmRequired: 'Please confirm your password', + passwordsMismatch: 'Passwords do not match', + haveAccount: 'Already have an account?', + signInLink: 'Sign in', + verificationSent: + 'A verification link has been sent to your email. Please check your inbox.', + }, + uk: { + title: 'Створити обліковий запис', + subtitle: 'Зареєструйтеся за допомогою email, щоб почати облік фінансів.', + email: 'Email', + emailPlaceholder: 'Введіть ваш email', + password: 'Пароль', + passwordPlaceholder: 'Створіть пароль', + confirmPassword: 'Підтвердження пароля', + confirmPlaceholder: 'Повторіть пароль', + submit: 'Зареєструватися', + emailRequired: "Email є обов'язковим", + emailInvalid: 'Введіть коректний email', + userExists: 'Користувач вже існує', + passwordRequired: "Пароль є обов'язковим", + passwordTooShort: 'Пароль має містити щонайменше 8 символів', + confirmRequired: 'Будь ласка, підтвердіть пароль', + passwordsMismatch: 'Паролі не збігаються', + haveAccount: 'Вже маєте обліковий запис?', + signInLink: 'Увійти', + verificationSent: + 'Лист з підтвердженням надіслано на ваш email. Будь ласка, перевірте вхідні.', + }, + cs: { + title: 'Vytvořit účet', + subtitle: 'Zaregistrujte se pomocí e-mailu a začněte sledovat své finance.', + email: 'E-mail', + emailPlaceholder: 'Zadejte svůj e-mail', + password: 'Heslo', + passwordPlaceholder: 'Vytvořte heslo', + confirmPassword: 'Potvrzení hesla', + confirmPlaceholder: 'Zopakujte heslo', + submit: 'Registrovat se', + emailRequired: 'E-mail je povinný', + emailInvalid: 'Zadejte platný e-mail', + userExists: 'Uživatel již existuje', + passwordRequired: 'Heslo je povinné', + passwordTooShort: 'Heslo musí mít alespoň 8 znaků', + confirmRequired: 'Potvrďte prosím své heslo', + passwordsMismatch: 'Hesla se neshodují', + haveAccount: 'Již máte účet?', + signInLink: 'Přihlásit se', + verificationSent: + 'Ověřovací odkaz byl odeslán na váš e-mail. Zkontrolujte prosím svou doručenou poštu.', + }, +}; diff --git a/src/app/core/layout/app-layout/app-layout.spec.ts b/src/app/core/layout/app-layout/app-layout.spec.ts index 92f9a76..4add89c 100644 --- a/src/app/core/layout/app-layout/app-layout.spec.ts +++ b/src/app/core/layout/app-layout/app-layout.spec.ts @@ -1,5 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { AppLayout } from './app-layout'; +import { MessageService } from 'primeng/api'; // eslint-disable-next-line @typescript-eslint/no-empty-function const noop = () => {}; @@ -25,6 +26,7 @@ describe('AppLayout', () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [AppLayout], + providers: [MessageService], }).compileComponents(); fixture = TestBed.createComponent(AppLayout); diff --git a/src/app/core/layout/form-page-layout/form-page-layout.ts b/src/app/core/layout/form-page-layout/form-page-layout.ts index d0c7352..38132ed 100644 --- a/src/app/core/layout/form-page-layout/form-page-layout.ts +++ b/src/app/core/layout/form-page-layout/form-page-layout.ts @@ -10,15 +10,17 @@ import { ButtonModule } from 'primeng/button'; selector: 'app-form-page-layout', imports: [ButtonModule], template: ` -
- -
+ @if (canBack()) { +
+ +
+ }

{{ header() }}

@@ -40,5 +42,6 @@ import { ButtonModule } from 'primeng/button'; export class FormPageLayout { readonly header = input(); readonly subheader = input(); + readonly canBack = input(true); readonly back = output(); } diff --git a/src/app/core/layout/setup-layout/setup-layout.spec.ts b/src/app/core/layout/setup-layout/setup-layout.spec.ts index eed2b85..f05841d 100644 --- a/src/app/core/layout/setup-layout/setup-layout.spec.ts +++ b/src/app/core/layout/setup-layout/setup-layout.spec.ts @@ -1,6 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { SetupLayout } from './setup-layout'; +import { MessageService } from 'primeng/api'; describe('SetupLayout', () => { let component: SetupLayout; @@ -8,7 +9,8 @@ describe('SetupLayout', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [SetupLayout] + imports: [SetupLayout], + providers: [MessageService], }) .compileComponents(); diff --git a/src/app/core/layout/sidebar/sidebar.spec.ts b/src/app/core/layout/sidebar/sidebar.spec.ts index 43f6b90..f304efc 100644 --- a/src/app/core/layout/sidebar/sidebar.spec.ts +++ b/src/app/core/layout/sidebar/sidebar.spec.ts @@ -1,5 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Sidebar } from './sidebar'; +import { MessageService } from 'primeng/api'; describe('Sidebar', () => { let component: Sidebar; @@ -17,6 +18,7 @@ describe('Sidebar', () => { await TestBed.configureTestingModule({ imports: [Sidebar], + providers: [MessageService], }).compileComponents(); fixture = TestBed.createComponent(Sidebar); diff --git a/src/app/core/layout/sidebar/sidebar.ts b/src/app/core/layout/sidebar/sidebar.ts index 00b2685..018858c 100644 --- a/src/app/core/layout/sidebar/sidebar.ts +++ b/src/app/core/layout/sidebar/sidebar.ts @@ -1,10 +1,10 @@ import { Component, DestroyRef, computed, inject, signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { Router } from '@angular/router'; +import { AuthService } from '@core/services/auth-service'; +import { LocalizationService } from '@core/services/localization.service'; import { NavigatorHelper } from '@core/services/navigator-helper'; import { ResponsiveHelper } from '@core/services/responsive-helper'; -import { LocalizationService } from '@core/services/localization.service'; -import { AuthStore } from '@core/store/auth-store'; import { ThemeSwitcher } from '@shared/ui/theme-switcher'; import { ButtonModule } from 'primeng/button'; import { Logo } from './logo'; @@ -28,7 +28,7 @@ export class Sidebar { private readonly navigatorHelper = inject(NavigatorHelper); private readonly responsiveHelper = inject(ResponsiveHelper); private readonly destroyRef = inject(DestroyRef); - private readonly authStore = inject(AuthStore); + private readonly auth = inject(AuthService); private readonly localizationService = inject(LocalizationService); private t = (key: string) => this.localizationService.translate(key); @@ -90,8 +90,7 @@ export class Sidebar { } protected async logout() { - await this.authStore.logOut(); - this.router.navigate(['login']); + await this.auth.logOut(); } private handleWidthChanges = (width: number) => { diff --git a/src/app/core/layout/top-bar/top-bar.spec.ts b/src/app/core/layout/top-bar/top-bar.spec.ts index d33175e..50d501b 100644 --- a/src/app/core/layout/top-bar/top-bar.spec.ts +++ b/src/app/core/layout/top-bar/top-bar.spec.ts @@ -1,5 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { TopBar } from './top-bar'; +import { MessageService } from 'primeng/api'; describe('TopBar', () => { let component: TopBar; @@ -8,6 +9,7 @@ describe('TopBar', () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [TopBar], + providers: [MessageService], }).compileComponents(); fixture = TestBed.createComponent(TopBar); diff --git a/src/app/core/layout/top-bar/top-bar.ts b/src/app/core/layout/top-bar/top-bar.ts index c2e4ec1..5a347d8 100644 --- a/src/app/core/layout/top-bar/top-bar.ts +++ b/src/app/core/layout/top-bar/top-bar.ts @@ -1,4 +1,5 @@ import { Component, computed, inject, input, signal } from '@angular/core'; +import { Router } from '@angular/router'; import { AuthService } from '@core/services/auth-service'; import { LocalizationService } from '@core/services/localization.service'; import { NavigatorHelper } from '@core/services/navigator-helper'; @@ -21,7 +22,11 @@ import { UserAvatar, UserData } from './user-avatar/user-avatar';
- +
`, host: { @@ -31,6 +36,7 @@ import { UserAvatar, UserData } from './user-avatar/user-avatar'; export class TopBar { readonly hasTitle = input(true); + private readonly router = inject(Router); private readonly navigatorHelper = inject(NavigatorHelper); private readonly auth = inject(AuthService); private readonly authStore = inject(AuthStore); @@ -73,6 +79,10 @@ export class TopBar { this.getUser(); } + protected goToSettings() { + this.router.navigate(['settings']); + } + protected async logout() { await this.auth.logOut(); } diff --git a/src/app/core/layout/top-bar/user-avatar/user-avatar.ts b/src/app/core/layout/top-bar/user-avatar/user-avatar.ts index 34888db..a9b8967 100644 --- a/src/app/core/layout/top-bar/user-avatar/user-avatar.ts +++ b/src/app/core/layout/top-bar/user-avatar/user-avatar.ts @@ -36,14 +36,23 @@ export interface UserData { (keydown.space)="menu.toggle($event)" > @if (user(); as user) { - - - + @if (user.imgUrl) { + + + } @else { + + + } } @else { } @@ -54,9 +63,15 @@ export class UserAvatar { private readonly localizationService = inject(LocalizationService); readonly user = input(undefined); + readonly settings = output(); readonly logout = output(); protected items = computed(() => [ + { + label: this.localizationService.translate('core.settings'), + icon: 'pi pi-cog', + command: () => this.settings.emit(), + }, { label: this.localizationService.translate('core.logout'), icon: 'pi pi-sign-out', diff --git a/src/app/core/repositories/auth-repository.ts b/src/app/core/repositories/auth-repository.ts index d994d8b..db0f32b 100644 --- a/src/app/core/repositories/auth-repository.ts +++ b/src/app/core/repositories/auth-repository.ts @@ -16,6 +16,7 @@ export class AuthRepository { name: (user.user_metadata['full_name'] as string) || '', email: (user.email as string) || '', imgUrl: (user.user_metadata['avatar_url'] as string) || '', + provider: (user.app_metadata['provider'] as string) || '', }; } else { return null; @@ -33,4 +34,40 @@ export class AuthRepository { async logOut() { await this.supabaseService.signOut(); } + + async signUp(credits: { email: string; password: string }) { + return this.supabaseService.signUp(credits); + } + + async loginWithEmail(credits: { email: string; password: string }) { + return this.supabaseService.signInWithEmail(credits); + } + + async resetPasswordForEmail(email: string) { + return this.supabaseService.resetPasswordForEmail(email); + } + + async updatePassword(password: string) { + return this.supabaseService.updatePassword(password); + } + + async signInWithPasskey() { + return this.supabaseService.signInWithPasskey(); + } + + async registerPasskey() { + return this.supabaseService.registerPasskey(); + } + + async listPasskeys() { + return this.supabaseService.listPasskeys(); + } + + async renamePasskey(passkeyId: string, friendlyName: string) { + return this.supabaseService.renamePasskey(passkeyId, friendlyName); + } + + async deletePasskey(passkeyId: string) { + return this.supabaseService.deletePasskey(passkeyId); + } } diff --git a/src/app/core/services/auth-service.spec.ts b/src/app/core/services/auth-service.spec.ts index ef933a9..e1fbab8 100644 --- a/src/app/core/services/auth-service.spec.ts +++ b/src/app/core/services/auth-service.spec.ts @@ -1,12 +1,13 @@ import { TestBed } from '@angular/core/testing'; import { AuthService } from './auth-service'; +import { MessageService } from 'primeng/api'; describe('AuthService', () => { let service: AuthService; beforeEach(() => { - TestBed.configureTestingModule({}); + TestBed.configureTestingModule({ providers: [MessageService] }); service = TestBed.inject(AuthService); }); diff --git a/src/app/core/services/auth-service.ts b/src/app/core/services/auth-service.ts index 3959365..b5f1873 100644 --- a/src/app/core/services/auth-service.ts +++ b/src/app/core/services/auth-service.ts @@ -1,16 +1,38 @@ -import { inject, Injectable } from '@angular/core'; +import { inject, Injectable, signal } from '@angular/core'; import { Router } from '@angular/router'; +import { AuthRepository } from '@core/repositories/auth-repository'; import { AuthStore } from '@core/store/auth-store'; +import { MessageService } from 'primeng/api'; import { AccountsLocalStorage, UiLocalStorage } from './local-storage'; +import { LocalizationService } from './localization.service'; +import { Passkey } from './supabase.service'; + +/** + * Shared shape of the errors returned by Supabase passkey calls (`AuthError` + * and `WebAuthnError`); the latter carries a structured `code` we key off of. + */ +interface PasskeyError { + message: string; + code?: string; +} @Injectable({ providedIn: 'root', }) export class AuthService { private readonly authStore = inject(AuthStore); + private readonly authRepository = inject(AuthRepository); private readonly router = inject(Router); private readonly accountsLocalStorage = inject(AccountsLocalStorage); private readonly uiLocalStorage = inject(UiLocalStorage); + private readonly messageService = inject(MessageService); + private readonly localizationService = inject(LocalizationService); + private t = (key: string) => this.localizationService.translate(key); + + readonly isConfirmationEmailSent = signal(false); + + readonly passkeys = signal([]); + readonly isLoadingPasskeys = signal(false); async loginWithGoogle(): Promise { this.authStore.loginWithGoogle(); @@ -27,4 +49,159 @@ export class AuthService { this.accountsLocalStorage.clear(); this.uiLocalStorage.clear(); } + + async signUp(credits: { email: string; password: string }) { + const { error } = await this.authStore.signUp(credits); + + if (error) { + this.messageService.add({ + life: 5000, + severity: 'error', + summary: 'Registration Error', + detail: error, + }); + } else { + this.isConfirmationEmailSent.set(true); + } + } + + async loginWithEmail(credits: { email: string; password: string }) { + const { success, error } = await this.authStore.loginWithEmail(credits); + if (success) { + this.router.navigate(['dashboard']); + } else { + this.messageService.add({ + life: 5000, + severity: 'error', + summary: 'Login Error', + detail: error as string, + }); + } + } + + async loginWithPasskey() { + const { success, error } = await this.authStore.loginWithPasskey(); + if (success) { + this.router.navigate(['dashboard']); + } else if (!this.isPasskeyCeremonyCancelled(error)) { + this.messageService.add({ + life: 5000, + severity: 'error', + summary: 'Login Error', + detail: error?.message ?? '', + }); + } + } + + async resetPasswordForEmail(email: string) { + return this.authStore.resetPasswordForEmail(email); + } + + async updatePassword(password: string, flow: 'reset' | 'update') { + const { success, error } = await this.authStore.updatePassword(password); + if (success) { + if (flow === 'reset') { + this.router.navigate(['dashboard']); + } else { + this.messageService.add({ + life: 5000, + severity: 'success', + summary: 'Password Updated', + detail: 'Your password has been updated successfully.', + }); + } + } else { + this.messageService.add({ + life: 5000, + severity: 'error', + summary: 'Update Password Error', + detail: error as string, + }); + } + } + + async loadPasskeys() { + this.isLoadingPasskeys.set(true); + try { + const { data, error } = await this.authRepository.listPasskeys(); + + if (error) { + this.showPasskeyError('settings.passkeys.loadError', error.message); + return; + } + + this.passkeys.set(data ?? []); + } catch (error) { + console.error('Error loading passkeys:', error); + } finally { + this.isLoadingPasskeys.set(false); + } + } + + async registerPasskey() { + await this.runPasskeyMutation( + () => this.authRepository.registerPasskey(), + 'settings.passkeys.addError', + 'settings.passkeys.addSuccess', + ); + } + + async renamePasskey(passkeyId: string, friendlyName: string) { + await this.runPasskeyMutation( + () => this.authRepository.renamePasskey(passkeyId, friendlyName), + 'settings.passkeys.renameError', + 'settings.passkeys.renameSuccess', + ); + } + + async deletePasskey(passkeyId: string) { + await this.runPasskeyMutation( + () => this.authRepository.deletePasskey(passkeyId), + 'settings.passkeys.deleteError', + 'settings.passkeys.deleteSuccess', + ); + } + + /** + * Runs a passkey create/rename/delete call, then refreshes the list and + * reports the outcome. A cancelled WebAuthn ceremony is silently ignored. + */ + private async runPasskeyMutation( + action: () => Promise<{ error: PasskeyError | null }>, + errorKey: string, + successKey: string, + ): Promise { + const { error } = await action(); + + if (error) { + if (!this.isPasskeyCeremonyCancelled(error)) { + this.showPasskeyError(errorKey, error.message); + } + return; + } + + this.messageService.add({ + life: 5000, + severity: 'success', + summary: this.t('settings.passkeys.title'), + detail: this.t(successKey), + }); + + await this.loadPasskeys(); + } + + // The user can cancel the browser WebAuthn prompt; the SDK surfaces this as a + // WebAuthnError with this code, which we treat as a no-op rather than a failure. + private isPasskeyCeremonyCancelled(error: PasskeyError | null): boolean { + return error?.code === 'ERROR_CEREMONY_ABORTED'; + } + + private showPasskeyError(summaryKey: string, detail: string) { + this.messageService.add({ + life: 5000, + severity: 'error', + summary: this.t(summaryKey), + detail, + }); + } } diff --git a/src/app/core/services/supabase.service.ts b/src/app/core/services/supabase.service.ts index ef12ada..6d81214 100644 --- a/src/app/core/services/supabase.service.ts +++ b/src/app/core/services/supabase.service.ts @@ -2,6 +2,7 @@ import { inject, Injectable } from '@angular/core'; import { environment } from '@env'; import { createClient, + PasskeyListItem, Session, SupabaseClient, User, @@ -9,6 +10,8 @@ import { import { DemoModeService } from './demo-mode/demo-mode.service'; import { DemoSupabaseClient } from './demo-mode/demo-supabase-client'; +export type Passkey = PasskeyListItem; + @Injectable({ providedIn: 'root', }) @@ -25,6 +28,7 @@ export class SupabaseService { { auth: { detectSessionInUrl: true, + experimental: { passkey: true }, }, }, ); @@ -39,19 +43,18 @@ export class SupabaseService { return this.demoClient as unknown as SupabaseClient; } + return this.getClient(); + } + + private getClient(): SupabaseClient { if (!this.supabase) { throw new Error('Supabase client is not initialized'); } - return this.supabase; } async signInWithGoogle() { - if (!this.supabase) { - throw new Error('Supabase client is not initialized'); - } - - const { error } = await this.supabase.auth.signInWithOAuth({ + const { error } = await this.getClient().auth.signInWithOAuth({ provider: 'google', options: { redirectTo: 'http://localhost:4200/dashboard' }, }); @@ -63,11 +66,7 @@ export class SupabaseService { } async signInWithDemo() { - if (!this.supabase) { - throw new Error('Supabase client is not initialized'); - } - - const { error } = await this.supabase.auth.signInWithPassword({ + const { error } = await this.getClient().auth.signInWithPassword({ email: 'demo@mail.com', password: 'password1234', }); @@ -79,11 +78,7 @@ export class SupabaseService { } async signOut() { - if (!this.supabase) { - throw new Error('Supabase client is not initialized'); - } - - const { error } = await this.supabase.auth.signOut(); + const { error } = await this.getClient().auth.signOut(); if (error) { console.error('Error signing out:', error); throw error; @@ -91,14 +86,10 @@ export class SupabaseService { } async getSession(): Promise { - if (!this.supabase) { - throw new Error('Supabase client is not initialized'); - } - const { data: { session }, error, - } = await this.supabase.auth.getSession(); + } = await this.getClient().auth.getSession(); if (error) { console.error('Error getting session:', error); return null; @@ -107,18 +98,52 @@ export class SupabaseService { } async getUser(): Promise { - if (!this.supabase) { - throw new Error('Supabase client is not initialized'); - } - const { data: { user }, error, - } = await this.supabase.auth.getUser(); + } = await this.getClient().auth.getUser(); if (error) { console.error('Error getting user:', error); return null; } return user; } + + async signUp(credits: { email: string; password: string }) { + return this.getClient().auth.signUp(credits); + } + + async signInWithEmail(credits: { email: string; password: string }) { + return this.getClient().auth.signInWithPassword(credits); + } + + async resetPasswordForEmail(email: string) { + return this.getClient().auth.resetPasswordForEmail(email, { + redirectTo: `${window.location.origin}/change-password`, + }); + } + + async updatePassword(password: string) { + return this.getClient().auth.updateUser({ password }); + } + + async signInWithPasskey() { + return this.getClient().auth.signInWithPasskey(); + } + + async registerPasskey() { + return this.getClient().auth.registerPasskey(); + } + + async listPasskeys() { + return this.getClient().auth.passkey.list(); + } + + async renamePasskey(passkeyId: string, friendlyName: string) { + return this.getClient().auth.passkey.update({ passkeyId, friendlyName }); + } + + async deletePasskey(passkeyId: string) { + return this.getClient().auth.passkey.delete({ passkeyId }); + } } diff --git a/src/app/core/store/auth-store.ts b/src/app/core/store/auth-store.ts index f3fe1e9..5e86254 100644 --- a/src/app/core/store/auth-store.ts +++ b/src/app/core/store/auth-store.ts @@ -16,6 +16,7 @@ export interface AuthState { email: string; name: string; imgUrl: string; + provider: string; } | null; } @@ -32,59 +33,118 @@ export const AuthStore = signalStore( isAuthorized: () => !!store.user(), })), - withMethods(( - store, - authRepository = inject(AuthRepository), - demoModeService = inject(DemoModeService), - ) => ({ - async init(): Promise { - if (store.isAuthorized()) { - return; - } - - patchState(store, () => ({ - isInitializing: true, - })); - - try { + withMethods( + ( + store, + authRepository = inject(AuthRepository), + demoModeService = inject(DemoModeService), + ) => ({ + async init(): Promise { + if (store.isAuthorized()) { + return; + } + + patchState(store, () => ({ + isInitializing: true, + })); + + try { + const user = await authRepository.getUser(); + patchState(store, () => ({ + isInitializing: false, + user, + })); + } catch { + patchState(store, () => ({ + isInitializing: false, + })); + } + }, + + async loginWithGoogle() { + await authRepository.loginWithGoogle(); const user = await authRepository.getUser(); + patchState(store, () => ({ - isInitializing: false, user, })); - } catch { + }, + + async loginWithDemo() { + await authRepository.loginWithDemo(); + const user = await authRepository.getUser(); + demoModeService.enable(); + patchState(store, () => ({ - isInitializing: false, + user, })); - } - }, - - async loginWithGoogle() { - await authRepository.loginWithGoogle(); - const user = await authRepository.getUser(); - - patchState(store, () => ({ - user, - })); - }, - - async loginWithDemo() { - await authRepository.loginWithDemo(); - const user = await authRepository.getUser(); - demoModeService.enable(); - - patchState(store, () => ({ - user, - })); - }, - - async logOut() { - await authRepository.logOut(); - demoModeService.disable(); - - patchState(store, () => ({ - user: null, - })); - }, - })), + }, + + async logOut() { + await authRepository.logOut(); + demoModeService.disable(); + + patchState(store, () => ({ + user: null, + errorCode: null, + })); + }, + + async signUp(credits: { email: string; password: string }) { + const { error } = await authRepository.signUp(credits); + + if (error) { + return { success: false, error: error.message }; + } else { + return { success: true, error: null }; + } + }, + + async loginWithEmail(credits: { email: string; password: string }) { + const { error } = await authRepository.loginWithEmail(credits); + + if (error) { + return { success: false, error: error.message }; + } else { + const user = await authRepository.getUser(); + patchState(store, { user }); + return { success: true, error: null }; + } + }, + + async loginWithPasskey() { + const { error } = await authRepository.signInWithPasskey(); + + if (error) { + // Return the full error so callers can read its structured `code` + // (e.g. to detect a cancelled WebAuthn ceremony). + return { success: false, error }; + } else { + const user = await authRepository.getUser(); + patchState(store, { user }); + return { success: true, error: null }; + } + }, + + async resetPasswordForEmail(email: string) { + const { error } = await authRepository.resetPasswordForEmail(email); + + if (error) { + return { success: false, error: error.message }; + } else { + return { success: true }; + } + }, + + async updatePassword(password: string) { + const { error } = await authRepository.updatePassword(password); + + if (error) { + return { success: false, error: error.message }; + } else { + return { success: true }; + } + }, + }), + ), ); diff --git a/src/app/features/auth/change-password/change-password.html b/src/app/features/auth/change-password/change-password.html new file mode 100644 index 0000000..2419d8d --- /dev/null +++ b/src/app/features/auth/change-password/change-password.html @@ -0,0 +1,75 @@ + +
+
+ + + @if ( + form.controls.newPassword.invalid && form.controls.newPassword.dirty + ) { +
+ @if (form.controls.newPassword.errors?.['required']) { + {{ 'changePassword.newPasswordRequired' | translate }} + } @else { + {{ 'changePassword.newPasswordTooShort' | translate }} + } +
+ } +
+ +
+ + + @if ( + form.controls.confirmPassword.dirty && + (form.controls.confirmPassword.invalid || + form.errors?.['passwordsMismatch']) + ) { +
+ @if (form.controls.confirmPassword.errors?.['required']) { + {{ 'changePassword.confirmRequired' | translate }} + } @else { + {{ 'changePassword.passwordsMismatch' | translate }} + } +
+ } +
+ +
+ +
+
+
diff --git a/src/app/features/auth/change-password/change-password.spec.ts b/src/app/features/auth/change-password/change-password.spec.ts new file mode 100644 index 0000000..b1fd296 --- /dev/null +++ b/src/app/features/auth/change-password/change-password.spec.ts @@ -0,0 +1,24 @@ +import { provideRouter } from '@angular/router'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ChangePassword } from './change-password'; +import { MessageService } from 'primeng/api'; + +describe('ChangePassword', () => { + let component: ChangePassword; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ChangePassword], + providers: [MessageService, provideRouter([])], + }).compileComponents(); + + fixture = TestBed.createComponent(ChangePassword); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/auth/change-password/change-password.ts b/src/app/features/auth/change-password/change-password.ts new file mode 100644 index 0000000..bf813e5 --- /dev/null +++ b/src/app/features/auth/change-password/change-password.ts @@ -0,0 +1,54 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { + FormBuilder, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { AuthService } from '@core/services/auth-service'; +import { TranslatePipe } from '@shared/pipes/translate.pipe'; +import { passwordsMatchValidator } from '@shared/utils/passwords-match-validator'; +import { AuthCard } from '@shared/ui/auth-card/auth-card'; +import { ButtonModule } from 'primeng/button'; +import { PasswordModule } from 'primeng/password'; + +@Component({ + imports: [ + ReactiveFormsModule, + PasswordModule, + ButtonModule, + TranslatePipe, + AuthCard, + ], + templateUrl: './change-password.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ChangePassword { + private readonly formBuilder = inject(FormBuilder); + private readonly router = inject(Router); + private readonly route = inject(ActivatedRoute); + private readonly auth = inject(AuthService); + + protected form = this.formBuilder.group( + { + newPassword: [ + null as string | null, + [Validators.required, Validators.minLength(8)], + ], + confirmPassword: [null as string | null, [Validators.required]], + }, + { validators: passwordsMatchValidator('newPassword', 'confirmPassword') }, + ); + + protected gotoBack() { + this.router.navigate(['..'], { relativeTo: this.route }); + } + + protected onSubmit() { + this.form.markAllAsDirty(); + + if (this.form.valid) { + this.auth.updatePassword(this.form.value.newPassword!, 'reset'); + } + } +} diff --git a/src/app/features/auth/change-password/index.ts b/src/app/features/auth/change-password/index.ts new file mode 100644 index 0000000..ad1c414 --- /dev/null +++ b/src/app/features/auth/change-password/index.ts @@ -0,0 +1 @@ +export * from './change-password'; diff --git a/src/app/features/login/index.ts b/src/app/features/auth/login/index.ts similarity index 100% rename from src/app/features/login/index.ts rename to src/app/features/auth/login/index.ts diff --git a/src/app/features/auth/login/login.html b/src/app/features/auth/login/login.html new file mode 100644 index 0000000..15d5bd1 --- /dev/null +++ b/src/app/features/auth/login/login.html @@ -0,0 +1,32 @@ + +
+ + + +
+ + + + + +
+ + + {{ 'login.badge' | translate }} + +
+
diff --git a/src/app/features/login/login.spec.ts b/src/app/features/auth/login/login.spec.ts similarity index 77% rename from src/app/features/login/login.spec.ts rename to src/app/features/auth/login/login.spec.ts index ec4c8fc..3192972 100644 --- a/src/app/features/login/login.spec.ts +++ b/src/app/features/auth/login/login.spec.ts @@ -1,5 +1,7 @@ +import { provideRouter } from '@angular/router'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Login } from './login'; +import { MessageService } from 'primeng/api'; describe('Login', () => { let component: Login; @@ -8,6 +10,7 @@ describe('Login', () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [Login], + providers: [MessageService, provideRouter([])], }).compileComponents(); fixture = TestBed.createComponent(Login); diff --git a/src/app/features/auth/login/login.ts b/src/app/features/auth/login/login.ts new file mode 100644 index 0000000..5711f3f --- /dev/null +++ b/src/app/features/auth/login/login.ts @@ -0,0 +1,44 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { Router } from '@angular/router'; +import { AuthService } from '@core/services/auth-service'; +import { TranslatePipe } from '@shared/pipes/translate.pipe'; +import { AuthCard } from '@shared/ui/auth-card/auth-card'; +import { AuthRedirectLink } from '@shared/ui/auth-redirect-link/auth-redirect-link'; +import { DemoButton } from './ui/demo-button/demo-button'; +import { EmailAuthButton } from './ui/email-auth-button/email-auth-button'; +import { GoogleButton } from './ui/google-button/google-button'; +import { PasskeyButton } from './ui/passkey-button/passkey-button'; + +@Component({ + imports: [ + AuthCard, + AuthRedirectLink, + GoogleButton, + DemoButton, + EmailAuthButton, + PasskeyButton, + TranslatePipe, + ], + templateUrl: './login.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class Login { + private readonly auth = inject(AuthService); + private readonly router = inject(Router); + + protected loginWithGoogle() { + this.auth.loginWithGoogle(); + } + + protected async loginWithDemo() { + await this.auth.loginWithDemo(); + } + + protected async loginWithPasskey() { + await this.auth.loginWithPasskey(); + } + + protected goToSignIn() { + this.router.navigate(['signin']); + } +} diff --git a/src/app/features/login/ui/demo-button/demo-button.spec.ts b/src/app/features/auth/login/ui/demo-button/demo-button.spec.ts similarity index 100% rename from src/app/features/login/ui/demo-button/demo-button.spec.ts rename to src/app/features/auth/login/ui/demo-button/demo-button.spec.ts diff --git a/src/app/features/login/ui/demo-button/demo-button.ts b/src/app/features/auth/login/ui/demo-button/demo-button.ts similarity index 100% rename from src/app/features/login/ui/demo-button/demo-button.ts rename to src/app/features/auth/login/ui/demo-button/demo-button.ts diff --git a/src/app/features/auth/login/ui/email-auth-button/email-auth-button.spec.ts b/src/app/features/auth/login/ui/email-auth-button/email-auth-button.spec.ts new file mode 100644 index 0000000..b09296a --- /dev/null +++ b/src/app/features/auth/login/ui/email-auth-button/email-auth-button.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { EmailAuthButton } from './email-auth-button'; + +describe('EmailAuthButton', () => { + let component: EmailAuthButton; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [EmailAuthButton], + }).compileComponents(); + + fixture = TestBed.createComponent(EmailAuthButton); + fixture.componentRef.setInput('label', 'Sign up'); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/auth/login/ui/email-auth-button/email-auth-button.ts b/src/app/features/auth/login/ui/email-auth-button/email-auth-button.ts new file mode 100644 index 0000000..56ff1e6 --- /dev/null +++ b/src/app/features/auth/login/ui/email-auth-button/email-auth-button.ts @@ -0,0 +1,39 @@ +import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; + +@Component({ + selector: 'app-email-auth-button', + template: ` + + `, + styles: ` + :host { + display: block; + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EmailAuthButton { + readonly label = input.required(); + readonly clickAction = output(); +} diff --git a/src/app/features/login/ui/google-button/google-button.spec.ts b/src/app/features/auth/login/ui/google-button/google-button.spec.ts similarity index 100% rename from src/app/features/login/ui/google-button/google-button.spec.ts rename to src/app/features/auth/login/ui/google-button/google-button.spec.ts diff --git a/src/app/features/login/ui/google-button/google-button.ts b/src/app/features/auth/login/ui/google-button/google-button.ts similarity index 100% rename from src/app/features/login/ui/google-button/google-button.ts rename to src/app/features/auth/login/ui/google-button/google-button.ts diff --git a/src/app/features/login/ui/google-pic/google-pic.ts b/src/app/features/auth/login/ui/google-pic/google-pic.ts similarity index 100% rename from src/app/features/login/ui/google-pic/google-pic.ts rename to src/app/features/auth/login/ui/google-pic/google-pic.ts diff --git a/src/app/features/login/ui/google-pic/pic.svg b/src/app/features/auth/login/ui/google-pic/pic.svg similarity index 100% rename from src/app/features/login/ui/google-pic/pic.svg rename to src/app/features/auth/login/ui/google-pic/pic.svg diff --git a/src/app/features/auth/login/ui/passkey-button/passkey-button.spec.ts b/src/app/features/auth/login/ui/passkey-button/passkey-button.spec.ts new file mode 100644 index 0000000..35369f3 --- /dev/null +++ b/src/app/features/auth/login/ui/passkey-button/passkey-button.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { PasskeyButton } from './passkey-button'; + +describe('PasskeyButton', () => { + let component: PasskeyButton; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PasskeyButton], + }).compileComponents(); + + fixture = TestBed.createComponent(PasskeyButton); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/auth/login/ui/passkey-button/passkey-button.ts b/src/app/features/auth/login/ui/passkey-button/passkey-button.ts new file mode 100644 index 0000000..0a7bfda --- /dev/null +++ b/src/app/features/auth/login/ui/passkey-button/passkey-button.ts @@ -0,0 +1,40 @@ +import { ChangeDetectionStrategy, Component, output } from '@angular/core'; +import { TranslatePipe } from '@shared/pipes/translate.pipe'; + +@Component({ + selector: 'app-passkey-button', + imports: [TranslatePipe], + template: ` + + `, + styles: ` + :host { + display: block; + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PasskeyButton { + readonly clickLogin = output(); +} diff --git a/src/app/features/auth/reset-password/index.ts b/src/app/features/auth/reset-password/index.ts new file mode 100644 index 0000000..d92c781 --- /dev/null +++ b/src/app/features/auth/reset-password/index.ts @@ -0,0 +1 @@ +export * from './reset-password'; diff --git a/src/app/features/auth/reset-password/reset-password.html b/src/app/features/auth/reset-password/reset-password.html new file mode 100644 index 0000000..33c5c00 --- /dev/null +++ b/src/app/features/auth/reset-password/reset-password.html @@ -0,0 +1,56 @@ + + @if (submitted()) { +
+ {{ 'resetPassword.successMessage' | translate }} +
+ } @else { +
+
+ + + @if (form.controls.email.invalid && form.controls.email.dirty) { +
+ @if (form.controls.email.errors?.['required']) { + {{ 'resetPassword.emailRequired' | translate }} + } @else { + {{ 'resetPassword.emailInvalid' | translate }} + } +
+ } +
+ +
+ +
+
+ } + + +
diff --git a/src/app/features/auth/reset-password/reset-password.spec.ts b/src/app/features/auth/reset-password/reset-password.spec.ts new file mode 100644 index 0000000..c87b089 --- /dev/null +++ b/src/app/features/auth/reset-password/reset-password.spec.ts @@ -0,0 +1,24 @@ +import { provideRouter } from '@angular/router'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ResetPassword } from './reset-password'; +import { MessageService } from 'primeng/api'; + +describe('ResetPassword', () => { + let component: ResetPassword; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ResetPassword], + providers: [MessageService, provideRouter([])], + }).compileComponents(); + + fixture = TestBed.createComponent(ResetPassword); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/auth/reset-password/reset-password.ts b/src/app/features/auth/reset-password/reset-password.ts new file mode 100644 index 0000000..b6622fd --- /dev/null +++ b/src/app/features/auth/reset-password/reset-password.ts @@ -0,0 +1,45 @@ +import { + ChangeDetectionStrategy, + Component, + inject, + signal, +} from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { AuthService } from '@core/services/auth-service'; +import { TranslatePipe } from '@shared/pipes/translate.pipe'; +import { AuthCard } from '@shared/ui/auth-card/auth-card'; +import { AuthRedirectLink } from '@shared/ui/auth-redirect-link/auth-redirect-link'; +import { ButtonModule } from 'primeng/button'; +import { InputTextModule } from 'primeng/inputtext'; + +@Component({ + imports: [ + AuthCard, + AuthRedirectLink, + ReactiveFormsModule, + InputTextModule, + ButtonModule, + TranslatePipe, + ], + templateUrl: './reset-password.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ResetPassword { + private readonly formBuilder = inject(FormBuilder); + private readonly auth = inject(AuthService); + + protected submitted = signal(false); + + protected form = this.formBuilder.group({ + email: [null as string | null, [Validators.required, Validators.email]], + }); + + protected async onSubmit() { + this.form.markAllAsDirty(); + + if (this.form.valid) { + await this.auth.resetPasswordForEmail(this.form.value.email as string); + this.submitted.set(true); + } + } +} diff --git a/src/app/features/auth/signin-with-email/index.ts b/src/app/features/auth/signin-with-email/index.ts new file mode 100644 index 0000000..8094704 --- /dev/null +++ b/src/app/features/auth/signin-with-email/index.ts @@ -0,0 +1 @@ +export * from './signin-with-email'; diff --git a/src/app/features/auth/signin-with-email/signin-with-email.html b/src/app/features/auth/signin-with-email/signin-with-email.html new file mode 100644 index 0000000..9ceff4a --- /dev/null +++ b/src/app/features/auth/signin-with-email/signin-with-email.html @@ -0,0 +1,80 @@ + +
+
+ + + @if (form.controls.email.invalid && form.controls.email.dirty) { +
+ @if (form.controls.email.errors?.['required']) { + {{ 'signin.emailRequired' | translate }} + } @else { + {{ 'signin.emailInvalid' | translate }} + } +
+ } +
+ +
+ + + @if (form.controls.password.invalid && form.controls.password.dirty) { +
+ {{ 'signin.passwordRequired' | translate }} +
+ } +
+ + + +
+ +
+
+ + +
diff --git a/src/app/features/auth/signin-with-email/signin-with-email.spec.ts b/src/app/features/auth/signin-with-email/signin-with-email.spec.ts new file mode 100644 index 0000000..ba2c888 --- /dev/null +++ b/src/app/features/auth/signin-with-email/signin-with-email.spec.ts @@ -0,0 +1,24 @@ +import { provideRouter } from '@angular/router'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { SigninWithEmail } from './signin-with-email'; +import { MessageService } from 'primeng/api'; + +describe('Signin', () => { + let component: SigninWithEmail; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SigninWithEmail], + providers: [MessageService, provideRouter([])], + }).compileComponents(); + + fixture = TestBed.createComponent(SigninWithEmail); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/auth/signin-with-email/signin-with-email.ts b/src/app/features/auth/signin-with-email/signin-with-email.ts new file mode 100644 index 0000000..52f3358 --- /dev/null +++ b/src/app/features/auth/signin-with-email/signin-with-email.ts @@ -0,0 +1,45 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { RouterLink } from '@angular/router'; +import { AuthService } from '@core/services/auth-service'; +import { TranslatePipe } from '@shared/pipes/translate.pipe'; +import { AuthCard } from '@shared/ui/auth-card/auth-card'; +import { AuthRedirectLink } from '@shared/ui/auth-redirect-link/auth-redirect-link'; +import { ButtonModule } from 'primeng/button'; +import { InputTextModule } from 'primeng/inputtext'; +import { PasswordModule } from 'primeng/password'; + +@Component({ + imports: [ + AuthCard, + AuthRedirectLink, + RouterLink, + ReactiveFormsModule, + InputTextModule, + PasswordModule, + ButtonModule, + TranslatePipe, + ], + templateUrl: './signin-with-email.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SigninWithEmail { + private readonly formBuilder = inject(FormBuilder); + private readonly auth = inject(AuthService); + + protected form = this.formBuilder.group({ + email: [null as string | null, [Validators.required, Validators.email]], + password: [null as string | null, [Validators.required]], + }); + + protected async onSubmit() { + this.form.markAllAsDirty(); + + if (this.form.valid) { + await this.auth.loginWithEmail({ + email: this.form.value.email as string, + password: this.form.value.password as string, + }); + } + } +} diff --git a/src/app/features/auth/signup/index.ts b/src/app/features/auth/signup/index.ts new file mode 100644 index 0000000..630f027 --- /dev/null +++ b/src/app/features/auth/signup/index.ts @@ -0,0 +1 @@ +export * from './signup'; diff --git a/src/app/features/auth/signup/signup.html b/src/app/features/auth/signup/signup.html new file mode 100644 index 0000000..8bda8c9 --- /dev/null +++ b/src/app/features/auth/signup/signup.html @@ -0,0 +1,112 @@ + + @if (emailSent()) { +
+
+ {{ 'signup.verificationSent' | translate }} +
+
+ } @else { +
+
+ + + @if (form.controls.email.invalid && form.controls.email.dirty) { +
+ @if (form.controls.email.errors?.['required']) { + {{ 'signup.emailRequired' | translate }} + } @else { + {{ 'signup.emailInvalid' | translate }} + } +
+ } +
+ +
+ + + @if (form.controls.password.invalid && form.controls.password.dirty) { +
+ @if (form.controls.password.errors?.['required']) { + {{ 'signup.passwordRequired' | translate }} + } @else { + {{ 'signup.passwordTooShort' | translate }} + } +
+ } +
+ +
+ + + @if ( + form.controls.confirmPassword.dirty && + (form.controls.confirmPassword.invalid || + form.errors?.['passwordsMismatch']) + ) { +
+ @if (form.controls.confirmPassword.errors?.['required']) { + {{ 'signup.confirmRequired' | translate }} + } @else { + {{ 'signup.passwordsMismatch' | translate }} + } +
+ } +
+ +
+ +
+
+ + + } +
diff --git a/src/app/features/auth/signup/signup.spec.ts b/src/app/features/auth/signup/signup.spec.ts new file mode 100644 index 0000000..6727bff --- /dev/null +++ b/src/app/features/auth/signup/signup.spec.ts @@ -0,0 +1,24 @@ +import { provideRouter } from '@angular/router'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Signup } from './signup'; +import { MessageService } from 'primeng/api'; + +describe('Signup', () => { + let component: Signup; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [Signup], + providers: [MessageService, provideRouter([])], + }).compileComponents(); + + fixture = TestBed.createComponent(Signup); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/auth/signup/signup.ts b/src/app/features/auth/signup/signup.ts new file mode 100644 index 0000000..cd82ec6 --- /dev/null +++ b/src/app/features/auth/signup/signup.ts @@ -0,0 +1,57 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { + FormBuilder, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { AuthService } from '@core/services/auth-service'; +import { TranslatePipe } from '@shared/pipes/translate.pipe'; +import { passwordsMatchValidator } from '@shared/utils/passwords-match-validator'; +import { AuthCard } from '@shared/ui/auth-card/auth-card'; +import { AuthRedirectLink } from '@shared/ui/auth-redirect-link/auth-redirect-link'; +import { ButtonModule } from 'primeng/button'; +import { InputTextModule } from 'primeng/inputtext'; +import { PasswordModule } from 'primeng/password'; + +@Component({ + imports: [ + AuthCard, + AuthRedirectLink, + ReactiveFormsModule, + InputTextModule, + PasswordModule, + ButtonModule, + TranslatePipe, + ], + templateUrl: './signup.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class Signup { + private readonly formBuilder = inject(FormBuilder); + private readonly auth = inject(AuthService); + + protected readonly emailSent = this.auth.isConfirmationEmailSent; + + protected form = this.formBuilder.group( + { + email: [null as string | null, [Validators.required, Validators.email]], + password: [ + null as string | null, + [Validators.required, Validators.minLength(6)], + ], + confirmPassword: [null as string | null, [Validators.required]], + }, + { validators: passwordsMatchValidator() }, + ); + + protected async onSubmit() { + this.form.markAllAsDirty(); + + if (this.form.valid) { + await this.auth.signUp({ + email: this.form.value.email as string, + password: this.form.value.password as string, + }); + } + } +} diff --git a/src/app/features/budget/pages/create-budget/create-budget.spec.ts b/src/app/features/budget/pages/create-budget/create-budget.spec.ts index 4b43a98..5364124 100644 --- a/src/app/features/budget/pages/create-budget/create-budget.spec.ts +++ b/src/app/features/budget/pages/create-budget/create-budget.spec.ts @@ -4,6 +4,7 @@ import { CategoriesStore } from '@core/store/categories-store'; import { BudgetStore } from '../../stores'; import { BudgetRepository } from '../../repositories'; import { CreateBudget } from './create-budget'; +import { MessageService } from 'primeng/api'; function makeBudgetStoreMock(currencyCode: string | null = 'USD') { return { @@ -44,6 +45,7 @@ describe('CreateBudget', () => { await TestBed.configureTestingModule({ imports: [CreateBudget], providers: [ + MessageService, provideRouter([]), makeBudgetStoreMock('USD'), makeCategoriesStoreMock(), @@ -74,6 +76,7 @@ describe('CreateBudget', () => { await TestBed.configureTestingModule({ imports: [CreateBudget], providers: [ + MessageService, provideRouter([]), makeBudgetStoreMock(null), makeCategoriesStoreMock(), diff --git a/src/app/features/budget/pages/create-budget/create-budget.ts b/src/app/features/budget/pages/create-budget/create-budget.ts index ef88fea..bc3dbbd 100644 --- a/src/app/features/budget/pages/create-budget/create-budget.ts +++ b/src/app/features/budget/pages/create-budget/create-budget.ts @@ -89,7 +89,7 @@ import { CreateBudgetTotals } from './create-budget-totals'; }`, host: { class: 'flex flex-col gap-6 w-full' }, changeDetection: ChangeDetectionStrategy.OnPush, - providers: [CreateBudgetStore, MessageService], + providers: [CreateBudgetStore], }) export class CreateBudget { protected readonly store = inject(CreateBudgetStore); diff --git a/src/app/features/goals/pages/edit-goal/edit-goal.ts b/src/app/features/goals/pages/edit-goal/edit-goal.ts index 0ead324..0f59f76 100644 --- a/src/app/features/goals/pages/edit-goal/edit-goal.ts +++ b/src/app/features/goals/pages/edit-goal/edit-goal.ts @@ -40,7 +40,7 @@ import { GoalNotFound } from './goal-not-found'; ], templateUrl: './edit-goal.html', changeDetection: ChangeDetectionStrategy.OnPush, - providers: [MessageService, ConfirmationService], + providers: [ConfirmationService], }) export class EditGoal { protected readonly goalsListStore = inject(GoalsListStore); diff --git a/src/app/features/goals/pages/goals/goals.spec.ts b/src/app/features/goals/pages/goals/goals.spec.ts index 3ffcfd2..f6c7e69 100644 --- a/src/app/features/goals/pages/goals/goals.spec.ts +++ b/src/app/features/goals/pages/goals/goals.spec.ts @@ -2,6 +2,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideRouter } from '@angular/router'; import { Goals } from './goals'; +import { MessageService } from 'primeng/api'; describe('Goals', () => { let component: Goals; @@ -19,7 +20,7 @@ describe('Goals', () => { await TestBed.configureTestingModule({ imports: [Goals], - providers: [provideRouter([])], + providers: [MessageService, provideRouter([])], }).compileComponents(); fixture = TestBed.createComponent(Goals); diff --git a/src/app/features/goals/pages/goals/goals.ts b/src/app/features/goals/pages/goals/goals.ts index 70f7f28..bc1c07a 100644 --- a/src/app/features/goals/pages/goals/goals.ts +++ b/src/app/features/goals/pages/goals/goals.ts @@ -35,7 +35,6 @@ import { EmptyGoalsListState } from './empty-goals-list-state'; WithdrawFromGoalDialog, EmptyGoalsListState, ], - providers: [MessageService], templateUrl: './goals.html', host: { class: 'flex flex-col gap-4' }, changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/src/app/features/groups-and-categories/groups-and-categories.spec.ts b/src/app/features/groups-and-categories/groups-and-categories.spec.ts index 5a09c1c..8fceac3 100644 --- a/src/app/features/groups-and-categories/groups-and-categories.spec.ts +++ b/src/app/features/groups-and-categories/groups-and-categories.spec.ts @@ -1,6 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { GroupsAndCategories } from './groups-and-categories'; +import { MessageService } from 'primeng/api'; describe('GroupsAndCategories', () => { let component: GroupsAndCategories; @@ -9,6 +10,7 @@ describe('GroupsAndCategories', () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [GroupsAndCategories], + providers: [MessageService], }).compileComponents(); fixture = TestBed.createComponent(GroupsAndCategories); diff --git a/src/app/features/groups-and-categories/groups-and-categories.ts b/src/app/features/groups-and-categories/groups-and-categories.ts index 8b75598..dfc9a97 100644 --- a/src/app/features/groups-and-categories/groups-and-categories.ts +++ b/src/app/features/groups-and-categories/groups-and-categories.ts @@ -23,7 +23,6 @@ import { NewGroupDialog } from './ui/new-group-dialog'; ], templateUrl: './groups-and-categories.html', changeDetection: ChangeDetectionStrategy.OnPush, - providers: [MessageService], }) export class GroupsAndCategories { protected readonly store = inject(GroupsAndCategoriesStore); diff --git a/src/app/features/login/login.html b/src/app/features/login/login.html deleted file mode 100644 index 2f97fed..0000000 --- a/src/app/features/login/login.html +++ /dev/null @@ -1,42 +0,0 @@ -
-
-
-
-
- -
-
- -
-

- {{ 'login.title' | translate }} -

-

- {{ 'login.subtitle' | translate }} -

-
- - - - -
- - - {{ 'login.badge' | translate }} - -
-
-
-
diff --git a/src/app/features/login/login.ts b/src/app/features/login/login.ts deleted file mode 100644 index 74d1611..0000000 --- a/src/app/features/login/login.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; -import { AuthService } from '@core/services/auth-service'; -import { TranslatePipe } from '@shared/pipes/translate.pipe'; -import { DemoButton } from './ui/demo-button/demo-button'; -import { GoogleButton } from './ui/google-button/google-button'; -import { LogoShort } from './ui/logo-short/logo-short'; - -@Component({ - imports: [LogoShort, GoogleButton, DemoButton, TranslatePipe], - templateUrl: './login.html', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class Login { - private readonly auth = inject(AuthService); - - - protected loginWithGoogle() { - this.auth.loginWithGoogle(); - } - - protected async loginWithDemo() { - await this.auth.loginWithDemo(); - } -} diff --git a/src/app/features/settings/index.ts b/src/app/features/settings/index.ts new file mode 100644 index 0000000..fa8ac58 --- /dev/null +++ b/src/app/features/settings/index.ts @@ -0,0 +1 @@ +export * from './settings'; diff --git a/src/app/features/settings/passkeys/index.ts b/src/app/features/settings/passkeys/index.ts new file mode 100644 index 0000000..d18b83c --- /dev/null +++ b/src/app/features/settings/passkeys/index.ts @@ -0,0 +1 @@ +export * from './passkeys'; diff --git a/src/app/features/settings/passkeys/passkeys.html b/src/app/features/settings/passkeys/passkeys.html new file mode 100644 index 0000000..1e1bfc7 --- /dev/null +++ b/src/app/features/settings/passkeys/passkeys.html @@ -0,0 +1,78 @@ + + +
+ + + @if (isLoading()) { +
+ +
+ } @else if (!passkeys().length) { +

+ {{ 'settings.passkeys.empty' | translate }} +

+ } @else { +
    + @for (passkey of passkeys(); track passkey.id) { +
  • + + +
    + + {{ + passkey.friendly_name || + ('settings.passkeys.unnamed' | translate) + }} + + + @if (passkey.created_at) { + {{ 'settings.passkeys.added' | translate }} + {{ passkey.created_at | date: 'mediumDate' }} + } + @if (passkey.last_used_at) { + · {{ 'settings.passkeys.lastUsed' | translate }} + {{ passkey.last_used_at | date: 'mediumDate' }} + } + +
    + +
    + + +
    +
  • + } +
+ } +
+ + diff --git a/src/app/features/settings/passkeys/passkeys.spec.ts b/src/app/features/settings/passkeys/passkeys.spec.ts new file mode 100644 index 0000000..beac9a7 --- /dev/null +++ b/src/app/features/settings/passkeys/passkeys.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MessageService } from 'primeng/api'; +import { Passkeys } from './passkeys'; + +describe('Passkeys', () => { + let component: Passkeys; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [Passkeys], + providers: [MessageService], + }).compileComponents(); + + fixture = TestBed.createComponent(Passkeys); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/settings/passkeys/passkeys.ts b/src/app/features/settings/passkeys/passkeys.ts new file mode 100644 index 0000000..d4c0b25 --- /dev/null +++ b/src/app/features/settings/passkeys/passkeys.ts @@ -0,0 +1,75 @@ +import { DatePipe } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + inject, + signal, +} from '@angular/core'; +import { AuthService } from '@core/services/auth-service'; +import { LocalizationService } from '@core/services/localization.service'; +import { Passkey } from '@core/services/supabase.service'; +import { TranslatePipe } from '@shared/pipes/translate.pipe'; +import { ConfirmationService } from 'primeng/api'; +import { ButtonModule } from 'primeng/button'; +import { ConfirmDialogModule } from 'primeng/confirmdialog'; +import { ProgressSpinnerModule } from 'primeng/progressspinner'; +import { RenamePasskeyDialog } from './rename-passkey-dialog'; + +@Component({ + selector: 'app-passkeys', + imports: [ + DatePipe, + ButtonModule, + ConfirmDialogModule, + ProgressSpinnerModule, + RenamePasskeyDialog, + TranslatePipe, + ], + templateUrl: './passkeys.html', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ConfirmationService], +}) +export class Passkeys { + private readonly auth = inject(AuthService); + private readonly confirmationService = inject(ConfirmationService); + private readonly localizationService = inject(LocalizationService); + private t = (key: string) => this.localizationService.translate(key); + + protected readonly passkeys = this.auth.passkeys; + protected readonly isLoading = this.auth.isLoadingPasskeys; + + protected readonly renameDialogVisible = signal(false); + protected readonly passkeyToRename = signal(null); + + constructor() { + void this.auth.loadPasskeys(); + } + + protected addPasskey() { + void this.auth.registerPasskey(); + } + + protected openRename(passkey: Passkey) { + this.passkeyToRename.set(passkey); + this.renameDialogVisible.set(true); + } + + protected onRenamed(friendlyName: string) { + const passkey = this.passkeyToRename(); + if (passkey) { + void this.auth.renamePasskey(passkey.id, friendlyName); + } + } + + protected confirmDelete(passkey: Passkey) { + this.confirmationService.confirm({ + message: this.t('settings.passkeys.deleteConfirm.message'), + header: this.t('settings.passkeys.deleteConfirm.header'), + icon: 'pi pi-exclamation-triangle', + acceptLabel: this.t('settings.passkeys.deleteConfirm.accept'), + rejectLabel: this.t('settings.passkeys.deleteConfirm.reject'), + acceptButtonProps: { severity: 'danger' }, + accept: () => void this.auth.deletePasskey(passkey.id), + }); + } +} diff --git a/src/app/features/settings/passkeys/rename-passkey-dialog/index.ts b/src/app/features/settings/passkeys/rename-passkey-dialog/index.ts new file mode 100644 index 0000000..ddbf3b6 --- /dev/null +++ b/src/app/features/settings/passkeys/rename-passkey-dialog/index.ts @@ -0,0 +1 @@ +export * from './rename-passkey-dialog'; diff --git a/src/app/features/settings/passkeys/rename-passkey-dialog/rename-passkey-dialog.spec.ts b/src/app/features/settings/passkeys/rename-passkey-dialog/rename-passkey-dialog.spec.ts new file mode 100644 index 0000000..78dac13 --- /dev/null +++ b/src/app/features/settings/passkeys/rename-passkey-dialog/rename-passkey-dialog.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RenamePasskeyDialog } from './rename-passkey-dialog'; + +describe('RenamePasskeyDialog', () => { + let component: RenamePasskeyDialog; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RenamePasskeyDialog], + }).compileComponents(); + + fixture = TestBed.createComponent(RenamePasskeyDialog); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/settings/passkeys/rename-passkey-dialog/rename-passkey-dialog.ts b/src/app/features/settings/passkeys/rename-passkey-dialog/rename-passkey-dialog.ts new file mode 100644 index 0000000..345ab74 --- /dev/null +++ b/src/app/features/settings/passkeys/rename-passkey-dialog/rename-passkey-dialog.ts @@ -0,0 +1,100 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + input, + model, + output, +} from '@angular/core'; +import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms'; +import { LocalizationService } from '@core/services/localization.service'; +import { TranslatePipe } from '@shared/pipes/translate.pipe'; +import { ButtonModule } from 'primeng/button'; +import { DialogModule } from 'primeng/dialog'; +import { InputTextModule } from 'primeng/inputtext'; + +@Component({ + selector: 'app-rename-passkey-dialog', + imports: [ + DialogModule, + ButtonModule, + InputTextModule, + ReactiveFormsModule, + TranslatePipe, + ], + template: ` + +
+ + + @if (name.invalid && name.dirty) { +
+ @if (name.hasError('required')) { + {{ 'settings.passkeys.renameDialog.required' | translate }} + } @else if (name.hasError('maxlength')) { + {{ maxLengthErrorMessage() }} + } +
+ } +
+ + +
+ `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RenamePasskeyDialog { + private readonly localizationService = inject(LocalizationService); + private t = (key: string) => this.localizationService.translate(key); + + readonly visible = model(false); + readonly currentName = input(''); + readonly renamed = output(); + + protected readonly name = new FormControl('', [ + Validators.required, + Validators.maxLength(60), + ]); + + protected readonly maxLengthErrorMessage = computed(() => + this.t('shared.maxLength').replace('{{n}}', '60'), + ); + + constructor() { + // Seed the input with the current name each time the dialog opens. + effect(() => { + if (this.visible()) { + this.name.setValue(this.currentName(), { emitEvent: false }); + } + }); + } + + protected onSave() { + this.name.markAsDirty(); + + if (this.name.valid) { + this.renamed.emit(this.name.value!.trim()); + this.visible.set(false); + } + } +} diff --git a/src/app/features/settings/settings.html b/src/app/features/settings/settings.html new file mode 100644 index 0000000..8e42da1 --- /dev/null +++ b/src/app/features/settings/settings.html @@ -0,0 +1,49 @@ + + + + @if (sectionOptions().length) { + + } + + @switch (selectedSection()) { + @case ('password') { + @if (isEmailProvider()) { +
+
+ {{ 'settings.updatePassword.title' | translate }} +
+

+ {{ 'settings.updatePassword.subtitle' | translate }} +

+ + +
+ } + } + @case ('passkeys') { +
+
+ {{ 'settings.passkeys.title' | translate }} +
+

+ {{ 'settings.passkeys.subtitle' | translate }} +

+ + +
+ } + } +
diff --git a/src/app/features/settings/settings.spec.ts b/src/app/features/settings/settings.spec.ts new file mode 100644 index 0000000..e31ece1 --- /dev/null +++ b/src/app/features/settings/settings.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Settings } from './settings'; +import { MessageService } from 'primeng/api'; + +describe('Settings', () => { + let component: Settings; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [Settings], + providers: [MessageService], + }).compileComponents(); + + fixture = TestBed.createComponent(Settings); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/settings/settings.ts b/src/app/features/settings/settings.ts new file mode 100644 index 0000000..99d29a8 --- /dev/null +++ b/src/app/features/settings/settings.ts @@ -0,0 +1,69 @@ +import { Location } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + signal, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { FormPageLayout } from '@core/layout/form-page-layout'; +import { LocalizationService } from '@core/services/localization.service'; +import { AuthStore } from '@core/store/auth-store'; +import { TranslatePipe } from '@shared/pipes/translate.pipe'; +import { SelectButtonModule } from 'primeng/selectbutton'; +import { ToastModule } from 'primeng/toast'; +import { Passkeys } from './passkeys'; +import { UpdatePassword } from './update-password'; + +type SettingsSection = 'password' | 'passkeys'; + +@Component({ + imports: [ + FormPageLayout, + SelectButtonModule, + ToastModule, + FormsModule, + TranslatePipe, + UpdatePassword, + Passkeys, + ], + templateUrl: './settings.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class Settings { + private readonly location = inject(Location); + private readonly localizationService = inject(LocalizationService); + private readonly authStore = inject(AuthStore); + private t = (key: string) => this.localizationService.translate(key); + + protected readonly isEmailProvider = computed( + () => this.authStore.user()?.provider === 'email', + ); + + protected readonly selectedSection = signal( + this.isEmailProvider() ? 'password' : 'passkeys', + ); + + protected readonly sectionOptions = computed(() => { + const options: { value: SettingsSection; label: string }[] = []; + + if (this.isEmailProvider()) { + options.push({ + value: 'password', + label: this.t('settings.sections.password'), + }); + } + + options.push({ + value: 'passkeys', + label: this.t('settings.sections.passkeys'), + }); + + return options; + }); + + protected gotoBack() { + this.location.back(); + } +} diff --git a/src/app/features/settings/update-password/index.ts b/src/app/features/settings/update-password/index.ts new file mode 100644 index 0000000..ded50b8 --- /dev/null +++ b/src/app/features/settings/update-password/index.ts @@ -0,0 +1 @@ +export * from './update-password'; diff --git a/src/app/features/settings/update-password/update-password.html b/src/app/features/settings/update-password/update-password.html new file mode 100644 index 0000000..5ce15b4 --- /dev/null +++ b/src/app/features/settings/update-password/update-password.html @@ -0,0 +1,69 @@ +
+
+ + + @if ( + form.controls.newPassword.invalid && form.controls.newPassword.dirty + ) { +
+ @if (form.controls.newPassword.errors?.['required']) { + {{ 'changePassword.newPasswordRequired' | translate }} + } @else { + {{ 'changePassword.newPasswordTooShort' | translate }} + } +
+ } +
+ +
+ + + @if ( + form.controls.confirmPassword.dirty && + (form.controls.confirmPassword.invalid || + form.errors?.['passwordsMismatch']) + ) { +
+ @if (form.controls.confirmPassword.errors?.['required']) { + {{ 'changePassword.confirmRequired' | translate }} + } @else { + {{ 'changePassword.passwordsMismatch' | translate }} + } +
+ } +
+ +
+ +
+
diff --git a/src/app/features/settings/update-password/update-password.spec.ts b/src/app/features/settings/update-password/update-password.spec.ts new file mode 100644 index 0000000..c1b0d3b --- /dev/null +++ b/src/app/features/settings/update-password/update-password.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { UpdatePassword } from './update-password'; +import { MessageService } from 'primeng/api'; + +describe('UpdatePassword', () => { + let component: UpdatePassword; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [UpdatePassword], + providers: [MessageService], + }).compileComponents(); + + fixture = TestBed.createComponent(UpdatePassword); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/settings/update-password/update-password.ts b/src/app/features/settings/update-password/update-password.ts new file mode 100644 index 0000000..6d5042b --- /dev/null +++ b/src/app/features/settings/update-password/update-password.ts @@ -0,0 +1,37 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { AuthService } from '@core/services/auth-service'; +import { TranslatePipe } from '@shared/pipes/translate.pipe'; +import { passwordsMatchValidator } from '@shared/utils/passwords-match-validator'; +import { ButtonModule } from 'primeng/button'; +import { PasswordModule } from 'primeng/password'; + +@Component({ + selector: 'app-update-password', + imports: [ReactiveFormsModule, PasswordModule, ButtonModule, TranslatePipe], + templateUrl: './update-password.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class UpdatePassword { + private readonly formBuilder = inject(FormBuilder); + private readonly auth = inject(AuthService); + + protected form = this.formBuilder.group( + { + newPassword: [ + null as string | null, + [Validators.required, Validators.minLength(8)], + ], + confirmPassword: [null as string | null, [Validators.required]], + }, + { validators: passwordsMatchValidator('newPassword', 'confirmPassword') }, + ); + + protected async onSubmit() { + this.form.markAllAsDirty(); + + if (this.form.valid) { + await this.auth.updatePassword(this.form.value.newPassword!, 'update'); + } + } +} diff --git a/src/app/features/setup-account/setup-account.spec.ts b/src/app/features/setup-account/setup-account.spec.ts index 6bf758d..749e13e 100644 --- a/src/app/features/setup-account/setup-account.spec.ts +++ b/src/app/features/setup-account/setup-account.spec.ts @@ -1,6 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { SetupAccount } from './setup-account'; +import { MessageService } from 'primeng/api'; describe('SetupAccount', () => { let component: SetupAccount; @@ -9,6 +10,7 @@ describe('SetupAccount', () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [SetupAccount], + providers: [MessageService], }).compileComponents(); fixture = TestBed.createComponent(SetupAccount); diff --git a/src/app/features/setup-account/setup-account.ts b/src/app/features/setup-account/setup-account.ts index 4e27674..d0dfa70 100644 --- a/src/app/features/setup-account/setup-account.ts +++ b/src/app/features/setup-account/setup-account.ts @@ -26,7 +26,6 @@ import { SetupAccountService } from './setup-account.service'; ], templateUrl: './setup-account.html', changeDetection: ChangeDetectionStrategy.OnPush, - providers: [MessageService], }) export class SetupAccount { private readonly router = inject(Router); diff --git a/src/app/features/transactions/pages/edit-transaction/edit-transaction.spec.ts b/src/app/features/transactions/pages/edit-transaction/edit-transaction.spec.ts index b21c705..a872667 100644 --- a/src/app/features/transactions/pages/edit-transaction/edit-transaction.spec.ts +++ b/src/app/features/transactions/pages/edit-transaction/edit-transaction.spec.ts @@ -2,6 +2,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, provideRouter } from '@angular/router'; import { EditTransaction } from './edit-transaction'; +import { MessageService } from 'primeng/api'; const mockActivatedRoute = { snapshot: { @@ -18,6 +19,7 @@ describe('EditTransaction', () => { await TestBed.configureTestingModule({ imports: [EditTransaction], providers: [ + MessageService, provideRouter([]), { provide: ActivatedRoute, useValue: mockActivatedRoute }, ], diff --git a/src/app/features/transactions/pages/edit-transaction/edit-transaction.ts b/src/app/features/transactions/pages/edit-transaction/edit-transaction.ts index 282f970..a231f6f 100644 --- a/src/app/features/transactions/pages/edit-transaction/edit-transaction.ts +++ b/src/app/features/transactions/pages/edit-transaction/edit-transaction.ts @@ -49,10 +49,8 @@ import { IncomeForm } from '../../ui/income-form'; ProgressSpinnerModule, FormPageLayout, ], - // LocalizationService is injected for toast messages only templateUrl: './edit-transaction.html', changeDetection: ChangeDetectionStrategy.OnPush, - providers: [MessageService], }) export class EditTransaction { protected readonly transactionsStore = inject(TransactionsStore); diff --git a/src/app/features/transactions/pages/transfer-details/transfer-details.spec.ts b/src/app/features/transactions/pages/transfer-details/transfer-details.spec.ts index 01e43be..77ba16d 100644 --- a/src/app/features/transactions/pages/transfer-details/transfer-details.spec.ts +++ b/src/app/features/transactions/pages/transfer-details/transfer-details.spec.ts @@ -2,6 +2,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideRouter } from '@angular/router'; import { TransferDetails } from './transfer-details'; +import { MessageService } from 'primeng/api'; describe('TransferDetails', () => { let component: TransferDetails; @@ -10,7 +11,7 @@ describe('TransferDetails', () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [TransferDetails], - providers: [provideRouter([])], + providers: [MessageService, provideRouter([])], }).compileComponents(); fixture = TestBed.createComponent(TransferDetails); diff --git a/src/app/features/transactions/pages/transfer-details/transfer-details.ts b/src/app/features/transactions/pages/transfer-details/transfer-details.ts index 4556edb..da94be5 100644 --- a/src/app/features/transactions/pages/transfer-details/transfer-details.ts +++ b/src/app/features/transactions/pages/transfer-details/transfer-details.ts @@ -28,7 +28,6 @@ import { TransferDetailsStore } from '../../store'; ], templateUrl: './transfer-details.html', changeDetection: ChangeDetectionStrategy.OnPush, - providers: [MessageService], }) export class TransferDetails { private readonly transferStore = inject(TransferDetailsStore); diff --git a/src/app/features/under-construction/under-construction.spec.ts b/src/app/features/under-construction/under-construction.spec.ts index 3d5728d..47e7b19 100644 --- a/src/app/features/under-construction/under-construction.spec.ts +++ b/src/app/features/under-construction/under-construction.spec.ts @@ -2,6 +2,23 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { UnderConstruction } from './under-construction'; +// eslint-disable-next-line @typescript-eslint/no-empty-function +const noop = () => {}; + +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: noop, + removeListener: noop, + addEventListener: noop, + removeEventListener: noop, + dispatchEvent: () => false, + }), +}); + describe('UnderConstruction', () => { let component: UnderConstruction; let fixture: ComponentFixture; diff --git a/src/app/features/wallet/pages/account-settings/account-settings.spec.ts b/src/app/features/wallet/pages/account-settings/account-settings.spec.ts index 5d3c6e7..aee1556 100644 --- a/src/app/features/wallet/pages/account-settings/account-settings.spec.ts +++ b/src/app/features/wallet/pages/account-settings/account-settings.spec.ts @@ -2,6 +2,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideRouter } from '@angular/router'; import { AccountSettings } from './account-settings'; +import { MessageService } from 'primeng/api'; describe('AccountSettings', () => { let component: AccountSettings; @@ -10,7 +11,7 @@ describe('AccountSettings', () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [AccountSettings], - providers: [provideRouter([])], + providers: [MessageService, provideRouter([])], }).compileComponents(); fixture = TestBed.createComponent(AccountSettings); diff --git a/src/app/features/wallet/pages/account-settings/account-settings.ts b/src/app/features/wallet/pages/account-settings/account-settings.ts index a444ec6..cad5e80 100644 --- a/src/app/features/wallet/pages/account-settings/account-settings.ts +++ b/src/app/features/wallet/pages/account-settings/account-settings.ts @@ -23,7 +23,6 @@ import { WalletAccountsStore } from '../../stores'; ], templateUrl: './account-settings.html', changeDetection: ChangeDetectionStrategy.OnPush, - providers: [MessageService], }) export class AccountSettings { protected readonly currenciesStore = inject(CurrenciesStore); diff --git a/src/app/features/wallet/pages/new-account/new-account.spec.ts b/src/app/features/wallet/pages/new-account/new-account.spec.ts index b620cd3..2eba220 100644 --- a/src/app/features/wallet/pages/new-account/new-account.spec.ts +++ b/src/app/features/wallet/pages/new-account/new-account.spec.ts @@ -2,6 +2,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideRouter } from '@angular/router'; import { NewAccount } from './new-account'; +import { MessageService } from 'primeng/api'; describe('NewAccount', () => { let component: NewAccount; @@ -10,7 +11,7 @@ describe('NewAccount', () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [NewAccount], - providers: [provideRouter([])], + providers: [MessageService, provideRouter([])], }).compileComponents(); fixture = TestBed.createComponent(NewAccount); diff --git a/src/app/features/wallet/pages/new-account/new-account.ts b/src/app/features/wallet/pages/new-account/new-account.ts index 64a4c38..2a34155 100644 --- a/src/app/features/wallet/pages/new-account/new-account.ts +++ b/src/app/features/wallet/pages/new-account/new-account.ts @@ -20,7 +20,6 @@ import { ToastModule } from 'primeng/toast'; ], templateUrl: './new-account.html', changeDetection: ChangeDetectionStrategy.OnPush, - providers: [MessageService], }) export class NewAccount { protected readonly currenciesStore = inject(CurrenciesStore); diff --git a/src/app/features/wallet/pages/transfer/transfer.spec.ts b/src/app/features/wallet/pages/transfer/transfer.spec.ts index 86a0001..3a628c6 100644 --- a/src/app/features/wallet/pages/transfer/transfer.spec.ts +++ b/src/app/features/wallet/pages/transfer/transfer.spec.ts @@ -2,6 +2,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideRouter } from '@angular/router'; import { Transfer } from './transfer'; +import { MessageService } from 'primeng/api'; describe('Transfer', () => { let component: Transfer; @@ -10,7 +11,7 @@ describe('Transfer', () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [Transfer], - providers: [provideRouter([])], + providers: [MessageService, provideRouter([])], }).compileComponents(); fixture = TestBed.createComponent(Transfer); diff --git a/src/app/features/wallet/pages/transfer/transfer.ts b/src/app/features/wallet/pages/transfer/transfer.ts index a14e062..1b5b1ff 100644 --- a/src/app/features/wallet/pages/transfer/transfer.ts +++ b/src/app/features/wallet/pages/transfer/transfer.ts @@ -43,7 +43,6 @@ import { WalletRepository } from '../../repositories'; ], templateUrl: './transfer.html', changeDetection: ChangeDetectionStrategy.OnPush, - providers: [MessageService], }) export class Transfer { protected readonly accountsStore = inject(AccountsStore); diff --git a/src/app/shared/ui/auth-card/auth-card.ts b/src/app/shared/ui/auth-card/auth-card.ts new file mode 100644 index 0000000..90f8228 --- /dev/null +++ b/src/app/shared/ui/auth-card/auth-card.ts @@ -0,0 +1,54 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { LogoShort } from '@shared/ui/logo-short/logo-short'; +import { ToastModule } from 'primeng/toast'; + +@Component({ + selector: 'app-auth-card', + imports: [LogoShort, ToastModule], + template: ` + + +
+
+ @if (showLogo()) { +
+
+ +
+
+ } + +
+

+ {{ title() }} +

+

+ {{ subtitle() }} +

+
+ + +
+
+ `, + host: { + class: + 'bg-[#FDFDFF] dark:bg-[#09090B] text-slate-900 dark:text-zinc-100 transition-colors duration-500 overflow-hidden', + }, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AuthCard { + readonly showLogo = input(true); + readonly title = input(); + readonly subtitle = input(); +} diff --git a/src/app/shared/ui/auth-redirect-link/auth-redirect-link.ts b/src/app/shared/ui/auth-redirect-link/auth-redirect-link.ts new file mode 100644 index 0000000..6a2ef8a --- /dev/null +++ b/src/app/shared/ui/auth-redirect-link/auth-redirect-link.ts @@ -0,0 +1,24 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { RouterLink } from '@angular/router'; + +@Component({ + selector: 'app-auth-redirect-link', + imports: [RouterLink], + template: ` +
+ {{ prompt() }} + + {{ linkLabel() }} + +
+ `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AuthRedirectLink { + readonly prompt = input.required(); + readonly linkLabel = input.required(); + readonly link = input.required(); +} diff --git a/src/app/features/login/ui/logo-short/logo-short.ts b/src/app/shared/ui/logo-short/logo-short.ts similarity index 100% rename from src/app/features/login/ui/logo-short/logo-short.ts rename to src/app/shared/ui/logo-short/logo-short.ts diff --git a/src/app/features/login/ui/logo-short/pic.svg b/src/app/shared/ui/logo-short/pic.svg similarity index 100% rename from src/app/features/login/ui/logo-short/pic.svg rename to src/app/shared/ui/logo-short/pic.svg diff --git a/src/app/shared/utils/passwords-match-validator.ts b/src/app/shared/utils/passwords-match-validator.ts new file mode 100644 index 0000000..5035fd5 --- /dev/null +++ b/src/app/shared/utils/passwords-match-validator.ts @@ -0,0 +1,23 @@ +import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; + +/** + * Builds a group-level validator that flags a `passwordsMismatch` error when + * the password and its confirmation differ. The control names are + * configurable so it can be reused across forms (e.g. `password` on sign-up, + * `newPassword` on password change). + */ +export function passwordsMatchValidator( + passwordKey = 'password', + confirmPasswordKey = 'confirmPassword', +): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const password = control.get(passwordKey)?.value; + const confirmPassword = control.get(confirmPasswordKey)?.value; + + if (!password || !confirmPassword || password === confirmPassword) { + return null; + } + + return { passwordsMismatch: true }; + }; +} diff --git a/supabase/config.toml b/supabase/config.toml index 8287fd3..86c41de 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -164,7 +164,10 @@ enabled = true # in emails. site_url = "http://localhost:4200" # A list of *exact* URLs that auth providers are permitted to redirect to post authentication. -additional_redirect_urls = ["https://localhost:4200"] +additional_redirect_urls = [ + "http://localhost:4200", + "http://localhost:4200/change-password", +] # How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). jwt_expiry = 3600 # JWT issuer URL. If not set, defaults to the local API URL (http://127.0.0.1:/auth/v1). @@ -183,7 +186,7 @@ enable_anonymous_sign_ins = false # Allow/disallow testing manual linking of accounts enable_manual_linking = false # Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more. -minimum_password_length = 6 +minimum_password_length = 8 # Passwords that do not meet the following requirements will be rejected as weak. Supported values # are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols` password_requirements = "" @@ -217,7 +220,7 @@ enable_signup = true # addresses. If disabled, only the new email is required to confirm. double_confirm_changes = true # If enabled, users need to confirm their email address before signing in. -enable_confirmations = false +enable_confirmations = true # If enabled, users will need to reauthenticate or have logged in recently to change their password. secure_password_change = false # Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email. @@ -242,6 +245,14 @@ otp_expiry = 3600 # subject = "You have been invited" # content_path = "./supabase/templates/invite.html" +[auth.email.template.confirmation] +subject = "Confirm your Finwaze account" +content_path = "./supabase/templates/confirm-email.html" + +[auth.email.template.recovery] +subject = "Reset your Finwaze password" +content_path = "./supabase/templates/reset-password.html" + # Uncomment to customize notification email template # [auth.email.notification.password_changed] # enabled = true @@ -372,6 +383,14 @@ authorization_url_path = "/oauth/consent" # Allow dynamic client registration allow_dynamic_registration = false +[auth.passkey] +enabled = true + +[auth.webauthn] +rp_display_name = "Finwaze local" +rp_id = "localhost" +rp_origins = ["http://localhost:4200"] + [edge_runtime] enabled = true # Supported request policies: `oneshot`, `per_worker`. diff --git a/supabase/snippets/get filtered transactions.sql b/supabase/snippets/get filtered transactions.sql index 26b2691..598b694 100644 --- a/supabase/snippets/get filtered transactions.sql +++ b/supabase/snippets/get filtered transactions.sql @@ -1,18 +1 @@ -select * from get_filtered_transactions( - -- p_category_ids BIGINT[] DEFAULT NULL - ARRAY[]::bigint[], - -- p_account_ids BIGINT[] DEFAULT NULL - null, - -- p_charged_currency_codes TEXT[] DEFAULT NULL - null, - -- p_transaction_currency_codes TEXT[] DEFAULT NULL - null, - -- p_transaction_type transactions.type % type DEFAULT NULL - null, - -- p_month DATE DEFAULT NULL - '2026-02-01', - -- p_page INTEGER DEFAULT 1 - null, - -- p_page_size INTEGER DEFAULT NULL - null - ); \ No newline at end of file +delete from auth.users where id = '840b0430-766b-4f05-b668-e9a839bcd97a'; \ No newline at end of file diff --git a/supabase/templates/confirm-email.html b/supabase/templates/confirm-email.html new file mode 100644 index 0000000..f72770f --- /dev/null +++ b/supabase/templates/confirm-email.html @@ -0,0 +1,204 @@ + + + + + + Confirm your email — Finwaze + + + + + + +
+ + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ Finwaze +
+
+

+ Confirm your email +

+
+

+ Thanks for signing up. Click the button below to verify + your email address and activate your account. +

+
+ + Confirm email + +
+
+
+

+ If the button doesn't work, copy and paste this link: +

+

+ + {{ .ConfirmationURL }} + +

+
+
+

+ This link expires in 1 hour. If you didn't create a Finwaze + account, you can safely ignore this email. +

+
+
+ + diff --git a/supabase/templates/reset-password.html b/supabase/templates/reset-password.html new file mode 100644 index 0000000..fb000f1 --- /dev/null +++ b/supabase/templates/reset-password.html @@ -0,0 +1,203 @@ + + + + + + Reset your password — Finwaze + + + + + + +
+ + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ Finwaze +
+
+

+ Reset your password +

+
+

+ Click the button below to choose a new password for your + Finwaze account. +

+
+ + Reset password + +
+
+
+

+ If the button doesn't work, copy and paste this link: +

+

+ + {{ .ConfirmationURL }} + +

+
+
+

+ This link expires in 1 hour. If you didn't request a password + reset, you can safely ignore this email. +

+
+
+ +