From d9c148367adc0114bd660a4a1d783cc07c5aa9c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=95=D0=B1=D0=B0=D0=BA=D0=BB=D0=B0=D0=BA=D0=BE=D0=B2?= Date: Sat, 21 Mar 2026 22:53:46 +0300 Subject: [PATCH] Implement foundational vault data models, service, and shared UI components. --- package-lock.json | 69 ++++++++++ package.json | 3 + src-tauri/Cargo.toml | 2 +- src/app/core/services/vault.service.ts | 61 +++++++++ src/app/models/account.dto.ts | 8 ++ src/app/models/account.model.ts | 50 +++++++ src/app/models/csv-export.visitor.ts | 33 +++++ src/app/models/user.model.ts | 18 +++ src/app/models/visitor.interface.ts | 5 + .../account-card/account-card.component.ts | 32 +++++ .../components/button/button.component.ts | 49 +++++++ .../notification/notification.component.ts | 60 ++++++++ .../password-input.component.ts | 129 ++++++++++++++++++ src/index.html | 4 + src/styles.css | 56 +++++++- 15 files changed, 577 insertions(+), 2 deletions(-) create mode 100644 src/app/core/services/vault.service.ts create mode 100644 src/app/models/account.dto.ts create mode 100644 src/app/models/account.model.ts create mode 100644 src/app/models/csv-export.visitor.ts create mode 100644 src/app/models/user.model.ts create mode 100644 src/app/models/visitor.interface.ts create mode 100644 src/app/shared/components/account-card/account-card.component.ts create mode 100644 src/app/shared/components/button/button.component.ts create mode 100644 src/app/shared/components/notification/notification.component.ts create mode 100644 src/app/shared/components/password-input/password-input.component.ts diff --git a/package-lock.json b/package-lock.json index 8d42e00..81604a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,9 +26,12 @@ "@eslint/js": "^10.0.1", "@tauri-apps/cli": "^2.10.1", "angular-eslint": "21.3.1", + "autoprefixer": "^10.4.27", "eslint": "^10.0.3", "jsdom": "^28.0.0", + "postcss": "^8.5.8", "prettier": "^3.8.1", + "tailwindcss": "^4.2.2", "typescript": "~5.9.2", "typescript-eslint": "8.56.1", "vitest": "^4.0.8" @@ -5475,6 +5478,43 @@ "node": ">=12" } }, + "node_modules/autoprefixer": { + "version": "10.4.27", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", + "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001774", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -6874,6 +6914,20 @@ "node": ">= 0.6" } }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, "node_modules/fresh": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", @@ -8806,6 +8860,13 @@ "postcss": "^8.4.31" } }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -9593,6 +9654,14 @@ "dev": true, "license": "MIT" }, + "node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/tar": { "version": "7.5.12", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.12.tgz", diff --git a/package.json b/package.json index 492a1d8..6c5ae4d 100644 --- a/package.json +++ b/package.json @@ -30,9 +30,12 @@ "@eslint/js": "^10.0.1", "@tauri-apps/cli": "^2.10.1", "angular-eslint": "21.3.1", + "autoprefixer": "^10.4.27", "eslint": "^10.0.3", "jsdom": "^28.0.0", + "postcss": "^8.5.8", "prettier": "^3.8.1", + "tailwindcss": "^4.2.2", "typescript": "~5.9.2", "typescript-eslint": "8.56.1", "vitest": "^4.0.8" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0f42c4e..40ac202 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -5,7 +5,7 @@ description = "Cross-platform password manager built with Tauri and Angular" authors = ["stopmnenepriyatno"] license = "MIT" repository = "https://github.com/stopmnenepriyatno/abdristus" -edition = "2024" +edition = "2026" rust-version = "1.94.0" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/src/app/core/services/vault.service.ts b/src/app/core/services/vault.service.ts new file mode 100644 index 0000000..538a1fe --- /dev/null +++ b/src/app/core/services/vault.service.ts @@ -0,0 +1,61 @@ +import { Injectable } from '@angular/core'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { Account } from '../../models/account.model'; +import { AccountDTO } from '../../models/account.dto'; + +@Injectable({ + providedIn: 'root' +}) +export class VaultService { + private _accounts = new BehaviorSubject([]); + public accounts$: Observable = this._accounts.asObservable(); + + constructor() { + // Initial mock data to simulate DB + this.loadMockData(); + } + + /** + * Clears the current session data when the system is locked. + */ + clear(): void { + this._accounts.next([]); + } + + public getAccountsSnapshot(): Account[] { + return this._accounts.value; + } + + // Helper mock methods for UI dev (normally IPC commands) + private loadMockData(): void { + const mockDTOs: AccountDTO[] = [ + { + id: '1', + service_name: 'Google', + encrypted_data: 'enc_123', + nonce: 'n_123', + updated_at: new Date().toISOString(), + is_deleted: false + }, + { + id: '2', + service_name: 'GitHub', + encrypted_data: 'enc_456', + nonce: 'n_456', + updated_at: new Date(Date.now() - 86400000).toISOString(), + is_deleted: false + } + ]; + + const mockAccounts = mockDTOs.map(dto => { + const account = new Account(dto, { + login: `user@${dto.service_name.toLowerCase()}.com`, + password: 'mock_password_123', + notes: 'Mock data for UI dev' + }); + return account; + }); + + this._accounts.next(mockAccounts); + } +} diff --git a/src/app/models/account.dto.ts b/src/app/models/account.dto.ts new file mode 100644 index 0000000..3c076e2 --- /dev/null +++ b/src/app/models/account.dto.ts @@ -0,0 +1,8 @@ +export interface AccountDTO { + id: string; + service_name: string; + encrypted_data: string; // The encrypted JSON chunk with login/password + nonce: string; + updated_at: string; // ISO date string + is_deleted: boolean; +} diff --git a/src/app/models/account.model.ts b/src/app/models/account.model.ts new file mode 100644 index 0000000..965ef9f --- /dev/null +++ b/src/app/models/account.model.ts @@ -0,0 +1,50 @@ +import { AccountDTO } from './account.dto'; +import { AccountVisitor } from './visitor.interface'; + +export class Account { + private _dto: AccountDTO; + private _decryptedData: Record; // Could be typed further (e.g., { login, password, notes, url }) + + constructor(dto: AccountDTO, decryptedData?: Record) { + this._dto = dto; + this._decryptedData = decryptedData || {}; + } + + get id(): string { + return this._dto.id; + } + + get serviceName(): string { + return this._dto.service_name; + } + + get updatedAt(): Date { + return new Date(this._dto.updated_at); + } + + get isDeleted(): boolean { + return this._dto.is_deleted; + } + + get decryptedData(): Record { + return this._decryptedData; + } + + set decryptedData(data: Record) { + this._decryptedData = data; + } + + /** + * Validates if the account currently has required fields decrypted (e.g., login, password). + */ + isValid(): boolean { + return !!this._decryptedData?.['login'] && !!this._decryptedData?.['password']; + } + + /** + * Implements the Visitor pattern for operations like export. + */ + accept(visitor: AccountVisitor): void { + visitor.visitAccount(this); + } +} diff --git a/src/app/models/csv-export.visitor.ts b/src/app/models/csv-export.visitor.ts new file mode 100644 index 0000000..8c0815c --- /dev/null +++ b/src/app/models/csv-export.visitor.ts @@ -0,0 +1,33 @@ +import { Account } from './account.model'; +import { AccountVisitor } from './visitor.interface'; + +export class CsvExportVisitor implements AccountVisitor { + private rows: string[] = []; + + constructor() { + // Add CSV header + this.rows.push('Service,Login,Password,Notes,UpdatedAt'); + } + + visitAccount(account: Account): void { + const data = account.decryptedData || {}; + const service = this.escapeCsv(account.serviceName); + const login = this.escapeCsv(data['login'] || ''); + const password = this.escapeCsv(data['password'] || ''); + const notes = this.escapeCsv(data['notes'] || ''); + const updatedAt = account.updatedAt.toISOString(); + + this.rows.push(`${service},${login},${password},${notes},${updatedAt}`); + } + + getCsv(): string { + return this.rows.join('\n'); + } + + private escapeCsv(value: string): string { + if (value.includes(',') || value.includes('"') || value.includes('\n')) { + return `"${value.replace(/"/g, '""')}"`; + } + return value; + } +} diff --git a/src/app/models/user.model.ts b/src/app/models/user.model.ts new file mode 100644 index 0000000..9dcb355 --- /dev/null +++ b/src/app/models/user.model.ts @@ -0,0 +1,18 @@ +export class User { + id: string; + name: string; + email?: string; + settings: { + autoLockTimer: number; // in minutes + theme: 'system' | 'dark' | 'light'; + }; + + constructor(id: string, name: string) { + this.id = id; + this.name = name; + this.settings = { + autoLockTimer: 5, + theme: 'dark' // Monolithic Vault default + }; + } +} diff --git a/src/app/models/visitor.interface.ts b/src/app/models/visitor.interface.ts new file mode 100644 index 0000000..5bbac75 --- /dev/null +++ b/src/app/models/visitor.interface.ts @@ -0,0 +1,5 @@ +import type { Account } from './account.model'; + +export interface AccountVisitor { + visitAccount(account: Account): void; +} diff --git a/src/app/shared/components/account-card/account-card.component.ts b/src/app/shared/components/account-card/account-card.component.ts new file mode 100644 index 0000000..88cd86a --- /dev/null +++ b/src/app/shared/components/account-card/account-card.component.ts @@ -0,0 +1,32 @@ +import { Component, Input, Output, EventEmitter } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Account } from '../../../models/account.model'; +import { ButtonComponent } from '../button/button.component'; + +@Component({ + selector: 'app-account-card', + standalone: true, + imports: [CommonModule, ButtonComponent], + template: ` +
+
+

{{ account.serviceName }}

+ @if (account.decryptedData?.['login']) { +

+ {{ account.decryptedData?.['login'] }} +

+ } +
+ +
+ Copy + Edit +
+
+ ` +}) +export class AccountCardComponent { + @Input() account!: Account; + @Output() copyClicked = new EventEmitter(); + @Output() editClicked = new EventEmitter(); +} diff --git a/src/app/shared/components/button/button.component.ts b/src/app/shared/components/button/button.component.ts new file mode 100644 index 0000000..e581cca --- /dev/null +++ b/src/app/shared/components/button/button.component.ts @@ -0,0 +1,49 @@ +import { Component, Input, Output, EventEmitter } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'app-button', + standalone: true, + imports: [CommonModule], + template: ` + + ` +}) +export class ButtonComponent { + @Input() type: 'button' | 'submit' | 'reset' = 'button'; + @Input() variant: 'primary' | 'secondary' | 'tertiary' = 'primary'; + @Input() disabled = false; + @Input() size: 'sm' | 'md' | 'lg' = 'md'; + @Output() clicked = new EventEmitter(); + + get baseClasses(): string { + switch (this.size) { + case 'sm': + return 'px-3 py-1.5 text-sm rounded-sm'; + case 'lg': + return 'px-6 py-3 text-lg rounded-[0.125rem]'; + case 'md': + default: + return 'px-4 py-2 text-base rounded-[0.125rem]'; // sm roundedness from DESIGN.md + } + } + + get variantClasses(): Record { + return { + primary: 'bg-[var(--color-primary-container)] text-[var(--color-on-primary-container)] hover:bg-opacity-90 disabled:opacity-50 disabled:cursor-not-allowed', + secondary: 'bg-transparent text-[var(--color-on-surface)] ghost-border hover:bg-[var(--color-surface-bright)] hover:bg-opacity-10', + tertiary: 'bg-transparent text-[var(--color-primary)] hover:underline px-0 py-0' + }; + } +} diff --git a/src/app/shared/components/notification/notification.component.ts b/src/app/shared/components/notification/notification.component.ts new file mode 100644 index 0000000..194736c --- /dev/null +++ b/src/app/shared/components/notification/notification.component.ts @@ -0,0 +1,60 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'app-notification', + standalone: true, + imports: [CommonModule], + template: ` + @if (visible) { +
+ + +
+ @if (type === 'success') { + + + + } + @if (type === 'error') { + + + + } +
+ + +
+

{{ title }}

+

{{ message }}

+
+ + + +
+ } + ` +}) +export class NotificationComponent implements OnInit { + @Input() title = ''; + @Input() message = ''; + @Input() type: 'success' | 'error' = 'success'; + @Input() duration = 5000; + + visible = true; + + ngOnInit() { + if (this.duration > 0) { + setTimeout(() => this.close(), this.duration); + } + } + + close() { + this.visible = false; + } +} diff --git a/src/app/shared/components/password-input/password-input.component.ts b/src/app/shared/components/password-input/password-input.component.ts new file mode 100644 index 0000000..1f894bd --- /dev/null +++ b/src/app/shared/components/password-input/password-input.component.ts @@ -0,0 +1,129 @@ +import { Component, Input, forwardRef } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms'; + +@Component({ + selector: 'app-password-input', + standalone: true, + imports: [CommonModule, FormsModule], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => PasswordInputComponent), + multi: true + } + ], + template: ` +
+
+ + +
+ + @if (showIndicator) { +
+
+
+
+ @if (strengthScore > 0) { + + {{ strengthLabel }} + + } +
+ } +
+ ` +}) +export class PasswordInputComponent implements ControlValueAccessor { + @Input() placeholder = 'Password'; + @Input() showIndicator = true; + @Input() disabled = false; + + showPassword = false; + value = ''; + strengthScore = 0; + strengthLabel = ''; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + onChange = (_val: string) => { /* default noop */ }; + onTouched = () => { /* default noop */ }; + + togglePassword() { + this.showPassword = !this.showPassword; + } + + onModelChange(val: string) { + this.value = val; + this.calculateStrength(val); + this.onChange(val); + } + + onModelTouched() { + this.onTouched(); + } + + writeValue(val: string): void { + if (val !== undefined) { + this.value = val; + this.calculateStrength(val); + } + } + + registerOnChange(fn: (val: string) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + private calculateStrength(password: string) { + if (!password) { + this.strengthScore = 0; + this.strengthLabel = ''; + return; + } + + let score = 0; + if (password.length >= 8) score++; + if (/[A-Z]/.test(password) && /[0-9]/.test(password)) score++; + if (/[^A-Za-z0-9]/.test(password)) score++; + + this.strengthScore = Math.min(score, 3); + if (this.strengthScore === 1) this.strengthLabel = 'weak'; + else if (this.strengthScore === 2) this.strengthLabel = 'fair'; + else if (this.strengthScore >= 3) this.strengthLabel = 'strong'; + } +} diff --git a/src/index.html b/src/index.html index 5b9de77..df92ef6 100644 --- a/src/index.html +++ b/src/index.html @@ -6,6 +6,10 @@ + + + + diff --git a/src/styles.css b/src/styles.css index 90d4ee0..5f8f8df 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1 +1,55 @@ -/* You can add global styles to this file, and also import other style files */ +@import "tailwindcss"; + +@theme { + --color-surface: #131313; + --color-surface-container-lowest: #0e0e0e; + --color-surface-container: #201f1f; + --color-surface-container-highest: #353534; + --color-surface-bright: rgba(255, 255, 255, 0.6); + --color-surface-low: #1c1c1c; + + --color-primary: #ffb694; + --color-primary-container: #c2571a; + --color-on-primary-container: #ffffff; + + --color-on-surface: #e5e2e1; + --color-on-surface-variant: #dec0b4; + --color-outline-variant: #574239; + + --font-inter: "Inter", sans-serif; + --font-jetbrains: "JetBrains Mono", monospace; + + /* Defaults */ + --font-sans: "Inter", sans-serif; + --font-mono: "JetBrains Mono", monospace; +} + +@layer base { + body { + background-color: var(--color-surface); + color: var(--color-on-surface); + font-family: var(--font-inter); + margin: 0; + padding: 0; + min-height: 100vh; + } +} + +@layer components { + /* The "Ghost Border" rule */ + .ghost-border { + border: 1px solid color-mix(in srgb, var(--color-outline-variant) 15%, transparent); + } + .ghost-border-t { + border-top: 1px solid color-mix(in srgb, var(--color-outline-variant) 15%, transparent); + } + .ghost-border-b { + border-bottom: 1px solid color-mix(in srgb, var(--color-outline-variant) 15%, transparent); + } + + /* Glassmorphism */ + .glass-panel { + background-color: color-mix(in srgb, var(--color-surface-bright) 60%, transparent); + backdrop-filter: blur(16px); + } +}