diff --git a/angular.json b/angular.json index df96668..3cc2f9b 100644 --- a/angular.json +++ b/angular.json @@ -80,6 +80,9 @@ }, "serve": { "builder": "@angular/build:dev-server", + "options": { + "proxyConfig": "proxy.conf.json" + }, "configurations": { "production": { "buildTarget": "sentinent-frontend:build:production" @@ -113,4 +116,4 @@ } } } -} \ No newline at end of file +} diff --git a/proxy.conf.json b/proxy.conf.json new file mode 100644 index 0000000..e7c761e --- /dev/null +++ b/proxy.conf.json @@ -0,0 +1,7 @@ +{ + "/api": { + "target": "http://127.0.0.1:8080", + "secure": false, + "changeOrigin": true + } +} diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index df324c4..2f9e5b3 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -1,12 +1,11 @@ import { Routes } from '@angular/router'; import { Login } from './components/login/login'; -import { Signup } from './components/signup/signup'; import { authGuard } from './guards/auth-guard'; import { CreateWorkspace } from './components/workspace/create-workspace'; export const routes: Routes = [ { path: 'login', component: Login }, - { path: 'signup', component: Signup }, + { path: 'signup', component: Login }, { path: 'dashboard', loadComponent: () => import('./components/dashboard/dashboard').then(m => m.Dashboard), diff --git a/src/app/components/login/login.css b/src/app/components/login/login.css index e69de29..556a354 100644 --- a/src/app/components/login/login.css +++ b/src/app/components/login/login.css @@ -0,0 +1,398 @@ +:host { + display: block; + --bg: #fafafa; + --fg: #0a0a0a; + --subtle: rgba(10, 10, 10, 0.03); + --border: rgba(10, 10, 10, 0.12); + --muted: rgba(10, 10, 10, 0.6); + --error: #dc2626; + --success: #16a34a; + --font-sans: Inter, sans-serif; + --font-display: "Space Grotesk", sans-serif; +} + +:host-context(.dark) { + --bg: #050505; + --fg: #fafafa; + --subtle: rgba(250, 250, 250, 0.05); + --border: rgba(250, 250, 250, 0.12); + --muted: rgba(250, 250, 250, 0.5); +} + +* { + box-sizing: border-box; +} + +.noise { + position: fixed; + inset: 0; + pointer-events: none; + z-index: 0; + opacity: 0.02; + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); +} + +.theme-toggle { + position: fixed; + top: 20px; + right: 20px; + z-index: 50; + width: 40px; + height: 40px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--bg); + cursor: pointer; + display: grid; + place-items: center; +} + +.theme-toggle:hover { + border-color: var(--fg); +} + +.theme-glyph { + display: block; + width: 18px; + height: 18px; + border-radius: 50%; + border: 1px solid rgba(10, 10, 10, 0.45); + background: linear-gradient(90deg, #0a0a0a 50%, #ffffff 50%); + transition: transform 0.2s ease; +} + +.theme-glyph.dark-active { + transform: rotate(180deg); +} + +.split-layout { + min-height: 100vh; + display: grid; + grid-template-columns: 1fr 480px; + background: var(--bg); + color: var(--fg); + font-family: var(--font-sans); +} + +.brand-side { + position: relative; + overflow: hidden; + background: var(--fg); + color: var(--bg); + padding: 48px; + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.brand-pattern { + position: absolute; + inset: 0; + opacity: 0.04; + background-image: radial-gradient(circle at 1px 1px, currentColor 1px, transparent 0); + background-size: 36px 36px; +} + +.brand-content, +.brand-footer { + position: relative; + z-index: 1; +} + +.brand-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 48px; +} + +.brand-icon { + width: 40px; + height: 40px; + border-radius: 10px; + background: var(--bg); + color: var(--fg); + display: grid; + place-items: center; +} + +.brand-name { + font-size: 28px; + font-weight: 700; + font-family: var(--font-display); +} + +h1 { + margin: 0 0 16px; + max-width: 560px; + font-family: var(--font-display); + font-size: 44px; + line-height: 1.12; +} + +.brand-copy { + margin: 0 0 36px; + max-width: 600px; + font-size: 18px; + line-height: 1.75; + opacity: 0.75; +} + +.trust-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 10px; + font-size: 13px; + letter-spacing: 0.06em; + text-transform: uppercase; + opacity: 0.65; +} + +.trust-list li::before { + content: "•"; + margin-right: 8px; +} + +.brand-footer { + font-size: 12px; + opacity: 0.5; +} + +.auth-side { + position: relative; + z-index: 1; + display: grid; + place-items: center; + padding: 36px; + background: var(--bg); +} + +.auth-shell { + width: 100%; + max-width: 360px; +} + +.auth-header h2 { + margin: 0 0 8px; + font-size: 32px; + font-family: var(--font-display); +} + +.auth-header p { + margin: 0 0 24px; + color: var(--muted); + font-size: 14px; +} + +.auth-tabs { + display: flex; + background: var(--subtle); + border-radius: 10px; + padding: 4px; + gap: 6px; + margin-bottom: 20px; +} + +.auth-tab { + border: none; + background: transparent; + color: var(--muted); + border-radius: 8px; + padding: 8px 12px; + font-weight: 600; + cursor: pointer; + flex: 1; +} + +.auth-tab.active { + background: var(--bg); + color: var(--fg); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12); +} + +.form-section { + display: grid; + gap: 10px; + animation: fadeIn 0.25s ease; +} + +.input-label { + font-size: 13px; + color: var(--muted); + font-weight: 600; +} + +.input-field { + width: 100%; + border: 1px solid var(--border); + border-radius: 10px; + padding: 12px 14px; + background: var(--bg); + color: var(--fg); +} + +.input-field:focus { + outline: none; + border-color: var(--fg); + box-shadow: 0 0 0 3px var(--subtle); +} + +.input-field.error { + border-color: var(--error); +} + +.form-row { + margin: 2px 0 6px; + width: 100%; + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + gap: 10px; +} + +.checkbox-wrapper { + display: flex; + align-items: center; + gap: 8px; + color: var(--muted); + font-size: 13px; + white-space: nowrap; +} + +.checkbox-wrapper input { + margin: 0; + flex: 0 0 auto; +} + +.checkbox-wrapper span { + white-space: nowrap; +} + +.checkbox-wrapper.terms { + margin-top: 6px; +} + +.checkbox-wrapper a, +.text-link { + color: var(--fg); +} + +.text-link { + border: none; + background: none; + font-size: 13px; + cursor: pointer; + white-space: nowrap; + justify-self: end; +} + +.btn-primary { + width: 100%; + border: none; + border-radius: 10px; + padding: 12px 14px; + background: var(--fg); + color: var(--bg); + font-family: var(--font-display); + font-size: 14px; + font-weight: 700; + cursor: pointer; +} + +.btn-primary:disabled { + opacity: 0.6; + cursor: default; +} + +.subtle-copy { + margin: 0 0 6px; + color: var(--muted); + font-size: 13px; +} + +.error-text, +.global-error { + margin: 0; + color: var(--error); + font-size: 13px; +} + +.back-btn { + border: none; + background: none; + color: var(--muted); + font-size: 13px; + cursor: pointer; + padding-top: 4px; +} + +.success-message { + text-align: center; + padding: 24px 8px 8px; +} + +.success-icon { + width: 48px; + height: 48px; + border-radius: 50%; + display: grid; + place-items: center; + background: var(--success); + color: #fff; + margin: 0 auto 14px; + font-size: 22px; +} + +.success-message h3 { + margin: 0 0 8px; +} + +.success-message p { + margin: 0; + color: var(--muted); +} + +.spinner { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid currentColor; + border-top-color: transparent; + border-radius: 50%; + margin-left: 8px; + animation: spin 0.6s linear infinite; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@media (max-width: 1024px) { + .split-layout { + grid-template-columns: 1fr; + } + + .brand-side { + display: none; + } + + .auth-side { + min-height: 100vh; + padding: 24px; + } +} diff --git a/src/app/components/login/login.html b/src/app/components/login/login.html index 4bb93e0..8911f46 100644 --- a/src/app/components/login/login.html +++ b/src/app/components/login/login.html @@ -1,16 +1,110 @@ -
-

Login

-
-
- - + + +
+ +
+
+
+
+
+
+ +
+ Sentinent +
+ +

The System of Record for Decisions

+

+ Join enterprise teams who replace chaotic Slack threads with structured, auditable decision-making. +

+ +
    +
  • SOC 2 Type II Certified
  • +
  • GDPR Compliant
  • +
  • 99.99% Uptime SLA
  • +
-
- - + + +
+ +
+
+
+

Welcome back

+

Sign in to access your Decision Ledger

+
+ + + + + + + + + + +
+ + +
+ + +

{{ loginError }}

+ + +
+ + + + + + + +

{{ registerError }}

+
+ +
+ + +

We'll send a password reset link to your email address.

+ + + +
+ +
+
+

{{ successTitle }}

+

{{ successText }}

+
+
- - -

{{ error }}

-

Don't have an account? Sign up

+
diff --git a/src/app/components/login/login.ts b/src/app/components/login/login.ts index 2af8e38..7687a5a 100644 --- a/src/app/components/login/login.ts +++ b/src/app/components/login/login.ts @@ -1,8 +1,10 @@ -import { Component, inject } from '@angular/core'; +import { CommonModule, DOCUMENT } from '@angular/common'; +import { HttpErrorResponse } from '@angular/common/http'; +import { ChangeDetectorRef, Component, OnInit, inject } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { Router, RouterModule } from '@angular/router'; +import { finalize, timeout } from 'rxjs'; import { AuthService } from '../../services/auth'; -import { CommonModule } from '@angular/common'; @Component({ selector: 'app-login', @@ -11,18 +13,220 @@ import { CommonModule } from '@angular/common'; templateUrl: './login.html', styleUrl: './login.css', }) -export class Login { - email = ''; - password = ''; - error = ''; +export class Login implements OnInit { + activeTab: 'login' | 'register' = 'login'; + + loginEmail = ''; + loginPassword = ''; + rememberMe = true; + loginError = ''; + registerError = ''; + + regEmail = ''; + regPassword = ''; + + forgotEmail = ''; + + showForgot = false; + showSuccess = false; + successTitle = 'Check your email'; + successText = 'We sent you a verification link.'; + + isDarkMode = false; + isLoginSubmitting = false; + isRegisterSubmitting = false; + isForgotSubmitting = false; private authService = inject(AuthService); private router = inject(Router); + private document = inject(DOCUMENT); + private cdr = inject(ChangeDetectorRef); + + ngOnInit(): void { + this.activeTab = this.router.url.startsWith('/signup') ? 'register' : 'login'; + const storedTheme = localStorage.getItem('theme'); + const prefersDark = typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches; + this.isDarkMode = storedTheme === 'dark' || (!storedTheme && prefersDark); + this.applyTheme(); + } + + toggleTheme(): void { + this.isDarkMode = !this.isDarkMode; + this.applyTheme(); + localStorage.setItem('theme', this.isDarkMode ? 'dark' : 'light'); + } + + switchTab(tab: 'login' | 'register'): void { + this.activeTab = tab; + this.showForgot = false; + this.showSuccess = false; + this.loginError = ''; + this.registerError = ''; + this.router.navigate([tab === 'login' ? '/login' : '/signup']); + } + + showForgotPassword(): void { + this.showForgot = true; + this.showSuccess = false; + } - onSubmit() { - this.authService.login(this.email, this.password).subscribe({ - next: () => this.router.navigate(['/']), - error: (err) => this.error = 'Invalid credentials' + backToLogin(): void { + this.switchTab('login'); + } + + handleLogin(): void { + this.loginError = ''; + if (!this.loginEmail.trim() || !this.loginPassword.trim()) { + this.loginError = 'Invalid credentials'; + return; + } + this.isLoginSubmitting = true; + + this.authService.login(this.loginEmail.trim(), this.loginPassword).pipe( + timeout(8000), + finalize(() => { + this.isLoginSubmitting = false; + this.syncView(); + }) + ).subscribe({ + next: () => { + this.showSuccessMessage('Welcome back', 'Redirecting to your Decision Ledger...'); + this.syncView(); + setTimeout(() => this.router.navigate(['/dashboard']), 1200); + }, + error: (err: HttpErrorResponse) => { + const backendMessage = typeof err.error === 'string' ? err.error.toLowerCase() : ''; + if (err.status === 401 || backendMessage.includes('invalid credentials')) { + this.loginError = 'Invalid credentials'; + this.syncView(); + return; + } + this.loginError = 'Sign in failed. Please try again.'; + this.syncView(); + } }); } + + handleRegister(): void { + if (!this.regEmail.trim() || !this.regPassword.trim()) { + this.registerError = 'Invalid credentials'; + return; + } + this.isRegisterSubmitting = true; + this.registerError = ''; + + this.authService.signup(this.regEmail.trim(), this.regPassword).pipe( + timeout(8000), + finalize(() => { + this.isRegisterSubmitting = false; + this.syncView(); + }) + ).subscribe({ + next: () => { + this.regPassword = ''; + this.loginEmail = this.regEmail.trim(); + this.showForgot = false; + this.loginError = ''; + this.registerError = ''; + this.showSuccessMessage( + 'Account is successfully created', + 'You will be redirected to login page shortly.' + ); + this.syncView(); + setTimeout(() => { + this.activeTab = 'login'; + this.showSuccess = false; + this.router.navigate(['/login']); + this.syncView(); + }, 1500); + }, + error: (err: HttpErrorResponse) => { + this.activeTab = 'register'; + this.showForgot = false; + this.showSuccess = false; + + const backendMessage = typeof err.error === 'string' ? err.error.toLowerCase() : ''; + if (err.status === 409 || backendMessage.includes('already exists')) { + this.registerError = 'Email already exists'; + this.syncView(); + return; + } + this.registerError = 'Registration failed. Please try again.'; + this.syncView(); + } + }); + } + + handleForgot(): void { + this.isForgotSubmitting = true; + setTimeout(() => { + this.isForgotSubmitting = false; + this.showSuccessMessage('Check your email', 'We sent a password reset link.'); + }, 1200); + } + + private showSuccessMessage(title: string, text: string): void { + this.showForgot = false; + this.showSuccess = true; + this.successTitle = title; + this.successText = text; + } + + private applyTheme(): void { + const root = this.document.documentElement; + if (this.isDarkMode) { + root.classList.add('dark'); + } else { + root.classList.remove('dark'); + } + } + + get isRegisterDisabled(): boolean { + return !this.regEmail.trim() || !this.regPassword; + } + + get isAuthFormVisible(): boolean { + return !this.showForgot && !this.showSuccess; + } + + get isLoginVisible(): boolean { + return this.isAuthFormVisible && this.activeTab === 'login'; + } + + get isRegisterVisible(): boolean { + return this.isAuthFormVisible && this.activeTab === 'register'; + } + + get hasGlobalError(): boolean { + return !!this.loginError; + } + + clearGlobalError(): void { + this.loginError = ''; + } + + onLoginEmailInput(): void { + this.clearGlobalError(); + } + + onLoginPasswordInput(): void { + this.clearGlobalError(); + } + + onRegisterEmailInput(): void { + this.registerError = ''; + } + + onRegisterPasswordInput(): void { + this.registerError = ''; + } + + onForgotEmailInput(): void { + this.clearGlobalError(); + } + + private syncView(): void { + this.cdr.detectChanges(); + } + } diff --git a/src/app/services/auth.ts b/src/app/services/auth.ts index 00d9eba..2f58e01 100644 --- a/src/app/services/auth.ts +++ b/src/app/services/auth.ts @@ -1,6 +1,6 @@ import { Injectable, inject } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; -import { Observable, tap } from 'rxjs'; +import { HttpClient, HttpResponse } from '@angular/common/http'; +import { map, Observable, tap } from 'rxjs'; interface AuthResponse { token: string; @@ -11,11 +11,16 @@ interface AuthResponse { }) export class AuthService { private http = inject(HttpClient); - private apiUrl = 'http://localhost:8080/api'; + private apiUrl = '/api'; private tokenKey = 'sentinent_token'; - signup(email: string, password: string): Observable { - return this.http.post(`${this.apiUrl}/signup`, { email, password }); + signup(email: string, password: string): Observable { + return this.http.post(`${this.apiUrl}/signup`, { email, password }, { + observe: 'response', + responseType: 'text' + }).pipe( + map((_res: HttpResponse) => undefined) + ); } login(email: string, password: string): Observable { diff --git a/src/index.html b/src/index.html index 047cb55..32dadb0 100644 --- a/src/index.html +++ b/src/index.html @@ -6,6 +6,9 @@ + + + diff --git a/src/styles.css b/src/styles.css index 119f86c..53f96eb 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,47 +1,12 @@ -.auth-container { - max-width: 400px; - margin: 50px auto; - padding: 20px; - border: 1px solid #ccc; - border-radius: 8px; - box-shadow: 0 2px 4px rgba(0,0,0,0.1); +html, +body { + margin: 0; + padding: 0; + min-height: 100%; } -.form-group { - margin-bottom: 15px; -} - -.form-group label { - display: block; - margin-bottom: 5px; -} - -.form-group input { - width: 100%; - padding: 8px; +*, +*::before, +*::after { box-sizing: border-box; } - -button { - width: 100%; - padding: 10px; - background-color: #007bff; - color: white; - border: none; - border-radius: 4px; - cursor: pointer; -} - -button:hover { - background-color: #0056b3; -} - -.error { - color: red; - margin-top: 10px; -} - -.success { - color: green; - margin-top: 10px; -}