diff --git a/go.mod b/go.mod index 1fbc161..12a4266 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,8 @@ require ( ) require ( + github.com/andybalholm/brotli v1.0.6 // indirect + github.com/go-swiss/compress v0.0.0-20231015173048-c7b565746931 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect diff --git a/go.sum b/go.sum index d93ac9a..1827f81 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,7 @@ +github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= +github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/go-swiss/compress v0.0.0-20231015173048-c7b565746931 h1:4GONJghYPtbCcPDZXWhbgKgbK8tfmv/C7su6O72AZWw= +github.com/go-swiss/compress v0.0.0-20231015173048-c7b565746931/go.mod h1:atoBfZRTinNQQlYfu42MCp8E1yoKWhmohXj71lgRtfU= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= diff --git a/internal/auth/controller.go b/internal/auth/controller.go index 920e7ec..4bfd9b4 100644 --- a/internal/auth/controller.go +++ b/internal/auth/controller.go @@ -48,6 +48,22 @@ func (s *Service) Signup(w http.ResponseWriter, r *http.Request) { log.Printf("Error: %v", err) w.WriteHeader(http.StatusInternalServerError) } + token, err := s.createJWT(&user) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + res, err := json.Marshal(&LoginResponse{ + Token: token, + }) + if err != nil { + log.Println("Error: ", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.Write(res) } // Signup handles user signup by decoding request body, hashing the password, and saving user data to the database. @@ -86,7 +102,7 @@ func (s *Service) ChangePassword(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) } - w.WriteHeader(http.StatusOK) + w.WriteHeader(http.StatusNoContent) } // Login handles user login by decoding request body, verifying credentials, and returning a JWT token. diff --git a/web/package-lock.json b/web/package-lock.json index 9e60ca9..4d653d6 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -20,6 +20,7 @@ "marked": "^15.0.8", "postcss": "^8.5.3", "rxjs": "~7.8.0", + "tailwind-merge": "^3.3.0", "tailwindcss": "^4.1.3", "tslib": "^2.3.0", "zone.js": "~0.15.0" @@ -13581,6 +13582,16 @@ "node": ">=0.10" } }, + "node_modules/tailwind-merge": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.0.tgz", + "integrity": "sha512-fyW/pEfcQSiigd5SNn0nApUOxx0zB/dm6UDU/rEwc2c3sX2smWUNbapHv+QRqLGVp9GWX3THIa7MUGPo+YkDzQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.3.tgz", diff --git a/web/package.json b/web/package.json index 8446d49..91ce5b1 100644 --- a/web/package.json +++ b/web/package.json @@ -22,6 +22,7 @@ "marked": "^15.0.8", "postcss": "^8.5.3", "rxjs": "~7.8.0", + "tailwind-merge": "^3.3.0", "tailwindcss": "^4.1.3", "tslib": "^2.3.0", "zone.js": "~0.15.0" diff --git a/web/src/app/app.component.html b/web/src/app/app.component.html index b718240..8d68e02 100644 --- a/web/src/app/app.component.html +++ b/web/src/app/app.component.html @@ -6,6 +6,7 @@ diff --git a/web/src/app/app.component.spec.ts b/web/src/app/app.component.spec.ts index a6b0ab9..46a6e81 100644 --- a/web/src/app/app.component.spec.ts +++ b/web/src/app/app.component.spec.ts @@ -24,6 +24,8 @@ describe('AppComponent', () => { const fixture = TestBed.createComponent(AppComponent); fixture.detectChanges(); const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, frontend'); + expect(compiled.querySelector('h1')?.textContent).toContain( + 'Hello, frontend', + ); }); }); diff --git a/web/src/app/app.routes.ts b/web/src/app/app.routes.ts index dc6b04d..38c7fc3 100644 --- a/web/src/app/app.routes.ts +++ b/web/src/app/app.routes.ts @@ -3,9 +3,9 @@ import { HomeComponent } from './routes/home/home.component'; import { PostComponent } from './routes/post/post.component'; import { DashboardComponent } from './routes/dashboard/dashboard.component'; import { LoggedInGuard } from './shared/guards/logged-in.guard'; -import { PostEditorComponent } from './components/post-editor/post-editor.component'; import { CreatePostComponent } from './routes/post/create-post/create-post.component'; import { UpdatePostComponent } from './routes/post/update-post/update-post.component'; +import { AccountComponent } from './routes/dashboard/account/account.component'; export const routes: Routes = [ { path: '', component: HomeComponent }, @@ -22,8 +22,11 @@ export const routes: Routes = [ }, { path: 'dashboard', - component: DashboardComponent, canActivate: [LoggedInGuard], + children: [ + { path: 'account', component: AccountComponent }, + { path: '', component: DashboardComponent }, + ], + data: { requiresAuth: true }, }, - { path: 'tst', component: PostEditorComponent }, ]; diff --git a/web/src/app/components/button/button.component.html b/web/src/app/components/button/button.component.html new file mode 100644 index 0000000..e0c7412 --- /dev/null +++ b/web/src/app/components/button/button.component.html @@ -0,0 +1,8 @@ + diff --git a/web/src/app/components/button/button.component.spec.ts b/web/src/app/components/button/button.component.spec.ts new file mode 100644 index 0000000..15e6373 --- /dev/null +++ b/web/src/app/components/button/button.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ButtonComponent } from './button.component'; + +describe('ButtonComponent', () => { + let component: ButtonComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ButtonComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ButtonComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/web/src/app/components/button/button.component.ts b/web/src/app/components/button/button.component.ts new file mode 100644 index 0000000..bdd6f8a --- /dev/null +++ b/web/src/app/components/button/button.component.ts @@ -0,0 +1,33 @@ +import { NgIf } from '@angular/common'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { twMerge } from 'tailwind-merge'; + +@Component({ + selector: 'app-button', + imports: [NgIf, RouterLink], + templateUrl: './button.component.html', +}) +export class ButtonComponent { + @Output() click = new EventEmitter(); + @Input() disabled: boolean = false; + @Input() color: 'yellow' | 'orange' = 'yellow'; + @Input() link: null | string = null; + + getStyle() { + let col = 'a'; + + switch (this.color) { + case 'yellow': + col = 'bg-amber-400'; + break; + case 'orange': + col = 'bg-orange-400'; + } + + return twMerge( + 'p-1 px-5 transition-colors rounded-full hover:bg-amber-500 ', + col, + ); + } +} diff --git a/web/src/app/components/change-password/change-password.component.html b/web/src/app/components/change-password/change-password.component.html new file mode 100644 index 0000000..f68d02f --- /dev/null +++ b/web/src/app/components/change-password/change-password.component.html @@ -0,0 +1,21 @@ +
+ + + @if (form.invalid && this.form.touched) { +

+ Password must be at least 6 chars. +

+ } + +
diff --git a/web/src/app/components/change-password/change-password.component.spec.ts b/web/src/app/components/change-password/change-password.component.spec.ts new file mode 100644 index 0000000..dde28fb --- /dev/null +++ b/web/src/app/components/change-password/change-password.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ChangePasswordComponent } from './change-password.component'; + +describe('ChangePasswordComponent', () => { + let component: ChangePasswordComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ChangePasswordComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ChangePasswordComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/web/src/app/components/change-password/change-password.component.ts b/web/src/app/components/change-password/change-password.component.ts new file mode 100644 index 0000000..5fce94d --- /dev/null +++ b/web/src/app/components/change-password/change-password.component.ts @@ -0,0 +1,39 @@ +import { Component, EventEmitter, inject, Output, output } from '@angular/core'; +import { + FormControl, + FormGroup, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { AuthService } from '../../shared/services/auth.service'; + +@Component({ + selector: 'app-change-password', + imports: [ReactiveFormsModule], + templateUrl: './change-password.component.html', +}) +export class ChangePasswordComponent { + @Output() done = new EventEmitter(); + private auth = inject(AuthService); + form = new FormGroup({ + password: new FormControl('', [ + Validators.required, + Validators.minLength(6), + ]), + }); + + submit() { + if (this.form.invalid) { + this.form.markAllAsTouched(); + return; + } + + this.auth + .changePassword(this.form.controls.password.value!) + .then((success) => { + if (success) { + this.done.emit(); + } + }); + } +} diff --git a/web/src/app/components/modal/modal.component.html b/web/src/app/components/modal/modal.component.html index 8d4756f..eb3c9a4 100644 --- a/web/src/app/components/modal/modal.component.html +++ b/web/src/app/components/modal/modal.component.html @@ -1,6 +1,6 @@
diff --git a/web/src/app/routes/dashboard/account/account.component.html b/web/src/app/routes/dashboard/account/account.component.html new file mode 100644 index 0000000..67666c1 --- /dev/null +++ b/web/src/app/routes/dashboard/account/account.component.html @@ -0,0 +1,9 @@ +

Actions

+
+ logout + Change Password +
+ + + + diff --git a/web/src/app/routes/dashboard/account/account.component.spec.ts b/web/src/app/routes/dashboard/account/account.component.spec.ts new file mode 100644 index 0000000..1ec4ce5 --- /dev/null +++ b/web/src/app/routes/dashboard/account/account.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AccountComponent } from './account.component'; + +describe('AccountComponent', () => { + let component: AccountComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AccountComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(AccountComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/web/src/app/routes/dashboard/account/account.component.ts b/web/src/app/routes/dashboard/account/account.component.ts new file mode 100644 index 0000000..d3cba20 --- /dev/null +++ b/web/src/app/routes/dashboard/account/account.component.ts @@ -0,0 +1,19 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { AuthService } from '../../../shared/services/auth.service'; +import { ButtonComponent } from '../../../components/button/button.component'; +import { ModalComponent } from '../../../components/modal/modal.component'; +import { ChangePasswordComponent } from '../../../components/change-password/change-password.component'; + +@Component({ + selector: 'app-account', + imports: [ButtonComponent, ModalComponent, ChangePasswordComponent], + templateUrl: './account.component.html', +}) +export class AccountComponent { + auth = inject(AuthService); + changePWOpen: boolean = false; + + changePW() { + this.changePWOpen = true; + } +} diff --git a/web/src/app/routes/dashboard/dashboard.component.html b/web/src/app/routes/dashboard/dashboard.component.html index 29d1db0..30dcd41 100644 --- a/web/src/app/routes/dashboard/dashboard.component.html +++ b/web/src/app/routes/dashboard/dashboard.component.html @@ -18,17 +18,18 @@

TL;DR; {{ post.tldr }}

- Edit - - + diff --git a/web/src/app/routes/dashboard/dashboard.component.ts b/web/src/app/routes/dashboard/dashboard.component.ts index 7d27414..3368765 100644 --- a/web/src/app/routes/dashboard/dashboard.component.ts +++ b/web/src/app/routes/dashboard/dashboard.component.ts @@ -1,12 +1,12 @@ import { Component, effect, inject } from '@angular/core'; -import { PostEditorComponent } from '../../components/post-editor/post-editor.component'; import { NgFor } from '@angular/common'; import { PostsService } from '../../shared/services/posts.service'; -import { RouterLink, RouterOutlet } from '@angular/router'; +import { RouterLink } from '@angular/router'; +import { ButtonComponent } from '../../components/button/button.component'; @Component({ selector: 'app-admin', - imports: [NgFor, RouterLink], + imports: [NgFor, RouterLink, ButtonComponent], standalone: true, templateUrl: './dashboard.component.html', }) diff --git a/web/src/app/shared/services/auth.service.ts b/web/src/app/shared/services/auth.service.ts index d151a93..766cb50 100644 --- a/web/src/app/shared/services/auth.service.ts +++ b/web/src/app/shared/services/auth.service.ts @@ -8,6 +8,7 @@ import { } from '@angular/core'; import { LoginResponse, User, Claims } from '../interfaces/auth'; import { environment } from '../../../environments/environment'; +import { ActivatedRouteSnapshot, Router } from '@angular/router'; const JWT_KEY = 'token'; @@ -16,6 +17,7 @@ const JWT_KEY = 'token'; }) export class AuthService { private http = inject(HttpClient); + private router = inject(Router); jwt: WritableSignal = signal(null); claims: WritableSignal = signal(null); timeout: any | null = null; @@ -76,9 +78,32 @@ export class AuthService { .post(`${environment.apiRoot}/auth/login`, user) .subscribe((res) => this.jwt.set(res.token)); } + + signup(user: User) { + this.http + .post(`${environment.apiRoot}/auth/signup`, user) + .subscribe((res) => this.jwt.set(res.token)); + } + + changePassword(password: string): Promise { + return new Promise((resolve) => { + this.http + .put(`${environment.apiRoot}/auth/password`, { password: password }) + .subscribe({ + complete: () => resolve(true), + error: () => resolve(false), + }); + }); + } + logout() { this.http.delete(`${environment.apiRoot}/auth/logout`).subscribe(() => { this.jwt.set(null); + + // move away if protected + if (isOnProtectedRoute(this.router.routerState.snapshot.root)) { + this.router.navigateByUrl('/'); + } }); } @@ -111,3 +136,11 @@ function extractExpiration(token: string): Date { const jwt = parseJwt(token); return new Date(jwt.exp * 1000); } +function isOnProtectedRoute(route: ActivatedRouteSnapshot): boolean { + // If this segment has the flag, return true + if (route.data && route.data['requiresAuth']) { + return true; + } + // Recursively check children + return route.children.some((child) => isOnProtectedRoute(child)); +}