Compare commits
2 Commits
Author | SHA1 | Date | |
---|---|---|---|
30dac9f12f | |||
73ff28347a |
42
.gitignore
vendored
42
.gitignore
vendored
@ -1,2 +1,44 @@
|
|||||||
blog.db
|
blog.db
|
||||||
|
backend
|
||||||
**/.DS_Store
|
**/.DS_Store
|
||||||
|
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||||
|
|
||||||
|
# Compiled output
|
||||||
|
web/dist
|
||||||
|
web/tmp
|
||||||
|
web/out-tsc
|
||||||
|
web/bazel-out
|
||||||
|
|
||||||
|
# Node
|
||||||
|
web/node_modules
|
||||||
|
web/npm-debug.log
|
||||||
|
web/yarn-error.log
|
||||||
|
|
||||||
|
# IDEs and editors
|
||||||
|
web/.idea/
|
||||||
|
web/.project
|
||||||
|
web/.classpath
|
||||||
|
web/.c9/
|
||||||
|
web/*.launch
|
||||||
|
web/.settings/
|
||||||
|
web/*.sublime-workspace
|
||||||
|
|
||||||
|
# Visual Studio Code
|
||||||
|
web/.vscode/*
|
||||||
|
web/!.vscode/settings.json
|
||||||
|
web/!.vscode/tasks.json
|
||||||
|
web/!.vscode/launch.json
|
||||||
|
web/!.vscode/extensions.json
|
||||||
|
web/.history/*
|
||||||
|
|
||||||
|
# Miscellaneous
|
||||||
|
web/.angular/cache
|
||||||
|
web/.sass-cache/
|
||||||
|
web/connect.lock
|
||||||
|
web/coverage
|
||||||
|
web/libpeerconnection.log
|
||||||
|
testem.log
|
||||||
|
web/typings
|
||||||
|
|
||||||
|
# System files
|
||||||
|
web/Thumbs.db
|
||||||
|
2
go.mod
2
go.mod
@ -13,6 +13,8 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
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/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||||
|
4
go.sum
4
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 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
@ -48,6 +48,61 @@ func (s *Service) Signup(w http.ResponseWriter, r *http.Request) {
|
|||||||
log.Printf("Error: %v", err)
|
log.Printf("Error: %v", err)
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
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.
|
||||||
|
func (s *Service) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var err error
|
||||||
|
var login Login
|
||||||
|
var password []byte
|
||||||
|
|
||||||
|
claims, ok := ExtractClaims(r.Context())
|
||||||
|
if !ok {
|
||||||
|
log.Println("Error while extracting claims")
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = json.NewDecoder(r.Body).Decode(&login); err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len([]byte(login.Password)) > 72 {
|
||||||
|
fmt.Fprint(w, "Password to long, max 72 bytes")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if password, err = bcrypt.GenerateFromPassword([]byte(login.Password), 6); err != nil {
|
||||||
|
log.Println("Error: ", err)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.db.Model(&model.User{}).Where("id = ?", claims.UserID).Update("password", password).Error
|
||||||
|
if err != nil {
|
||||||
|
|
||||||
|
log.Printf("Error: %v", err)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login handles user login by decoding request body, verifying credentials, and returning a JWT token.
|
// Login handles user login by decoding request body, verifying credentials, and returning a JWT token.
|
||||||
|
@ -19,9 +19,7 @@ func CreateMux(cfg *config.Config) (r *mux.Router) {
|
|||||||
r.Use(cors.HandlerForOrigin("*"))
|
r.Use(cors.HandlerForOrigin("*"))
|
||||||
|
|
||||||
app(r.PathPrefix("/api").Subrouter(), cfg)
|
app(r.PathPrefix("/api").Subrouter(), cfg)
|
||||||
|
frontend(r.PathPrefix("/"))
|
||||||
frontend := web.Frontend
|
|
||||||
r.PathPrefix("/").Handler(middlewares.AddPrefix("/dist/frontend/browser", http.FileServerFS(frontend)))
|
|
||||||
|
|
||||||
r.Methods("OPTIONS").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
r.Methods("OPTIONS").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// The CORS middleware should set up the headers for you
|
// The CORS middleware should set up the headers for you
|
||||||
@ -31,15 +29,28 @@ func CreateMux(cfg *config.Config) (r *mux.Router) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func frontend(r *mux.Route) {
|
||||||
|
frontend := web.Frontend
|
||||||
|
r.Handler(
|
||||||
|
middlewares.AddPrefix("/dist/frontend/browser",
|
||||||
|
middlewares.FallbackFile(
|
||||||
|
frontend,
|
||||||
|
"/dist/frontend/browser/index.html",
|
||||||
|
http.FileServerFS(frontend),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
func app(r *mux.Router, cfg *config.Config) {
|
func app(r *mux.Router, cfg *config.Config) {
|
||||||
db := model.Init()
|
db := model.Init()
|
||||||
blg := posts.New(db)
|
blg := posts.New(db)
|
||||||
auth := auth.New(&cfg.Auth, db)
|
auth := auth.New(&cfg.Auth, db)
|
||||||
|
|
||||||
// auth
|
// auth
|
||||||
r.HandleFunc("/login", auth.Login).Methods("POST")
|
r.HandleFunc("/auth/login", auth.Login).Methods("POST")
|
||||||
r.HandleFunc("/signup", auth.Signup).Methods("POST")
|
r.HandleFunc("/auth/signup", auth.Signup).Methods("POST")
|
||||||
r.Handle("/logout", auth.Authenticated(auth.Logout)).Methods("DELETE")
|
r.Handle("/auth/password", auth.Authenticated(auth.ChangePassword)).Methods("PUT")
|
||||||
|
r.Handle("/auth/logout", auth.Authenticated(auth.Logout)).Methods("DELETE")
|
||||||
|
|
||||||
// Posts
|
// Posts
|
||||||
r.Handle("/posts", auth.Authenticated(blg.SavePost, model.RoleUser, model.RoleAdmin)).Methods("POST")
|
r.Handle("/posts", auth.Authenticated(blg.SavePost, model.RoleUser, model.RoleAdmin)).Methods("POST")
|
||||||
|
61
pkg/middlewares/fallback.go
Normal file
61
pkg/middlewares/fallback.go
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
package middlewares
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FallbackFile serves a fallback file if the next handler returns a 404.
|
||||||
|
// fs: The file system to serve the fallback file from.
|
||||||
|
// name: The name of the fallback file.
|
||||||
|
// next: The next handler to serve.
|
||||||
|
func FallbackFile(fs fs.FS, name string, next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
bw := &buffedResponseWriter{
|
||||||
|
w,
|
||||||
|
0,
|
||||||
|
bytes.NewBuffer([]byte{}),
|
||||||
|
}
|
||||||
|
next.ServeHTTP(bw, r)
|
||||||
|
|
||||||
|
if bw.statusCode == http.StatusNotFound {
|
||||||
|
bw.Clear()
|
||||||
|
|
||||||
|
http.ServeFileFS(bw, r, fs, name)
|
||||||
|
bw.WriteHeader(http.StatusOK)
|
||||||
|
bw.Header().Add("Content-Type", "text/html; charset=utf-8")
|
||||||
|
}
|
||||||
|
bw.Apply()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type buffedResponseWriter struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
statusCode int
|
||||||
|
buff *bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *buffedResponseWriter) WriteHeader(code int) {
|
||||||
|
b.statusCode = code
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *buffedResponseWriter) Write(p []byte) (int, error) {
|
||||||
|
return b.buff.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *buffedResponseWriter) Clear() {
|
||||||
|
b.buff.Reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *buffedResponseWriter) Apply() {
|
||||||
|
_, err := b.ResponseWriter.Write(b.buff.Bytes())
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Error while applying buffedResponseWriter: ", err)
|
||||||
|
b.ResponseWriter.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
if b.statusCode != 200 {
|
||||||
|
b.ResponseWriter.WriteHeader(b.statusCode)
|
||||||
|
}
|
||||||
|
}
|
11
web/package-lock.json
generated
11
web/package-lock.json
generated
@ -20,6 +20,7 @@
|
|||||||
"marked": "^15.0.8",
|
"marked": "^15.0.8",
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.3",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
|
"tailwind-merge": "^3.3.0",
|
||||||
"tailwindcss": "^4.1.3",
|
"tailwindcss": "^4.1.3",
|
||||||
"tslib": "^2.3.0",
|
"tslib": "^2.3.0",
|
||||||
"zone.js": "~0.15.0"
|
"zone.js": "~0.15.0"
|
||||||
@ -13581,6 +13582,16 @@
|
|||||||
"node": ">=0.10"
|
"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": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "4.1.3",
|
"version": "4.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.3.tgz",
|
||||||
|
@ -22,6 +22,7 @@
|
|||||||
"marked": "^15.0.8",
|
"marked": "^15.0.8",
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.3",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
|
"tailwind-merge": "^3.3.0",
|
||||||
"tailwindcss": "^4.1.3",
|
"tailwindcss": "^4.1.3",
|
||||||
"tslib": "^2.3.0",
|
"tslib": "^2.3.0",
|
||||||
"zone.js": "~0.15.0"
|
"zone.js": "~0.15.0"
|
||||||
|
@ -6,6 +6,7 @@
|
|||||||
</a>
|
</a>
|
||||||
<nav class="flex gap-5">
|
<nav class="flex gap-5">
|
||||||
<a routerLink="/dashboard" *ngIf="loggedIn()">posts</a>
|
<a routerLink="/dashboard" *ngIf="loggedIn()">posts</a>
|
||||||
|
<a routerLink="/dashboard/account" *ngIf="loggedIn()">account</a>
|
||||||
<button *ngIf="!loggedIn()" (click)="toggleLogin()">login</button>
|
<button *ngIf="!loggedIn()" (click)="toggleLogin()">login</button>
|
||||||
<button *ngIf="loggedIn()" (click)="logOut()">logout</button>
|
<button *ngIf="loggedIn()" (click)="logOut()">logout</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
@ -24,6 +24,8 @@ describe('AppComponent', () => {
|
|||||||
const fixture = TestBed.createComponent(AppComponent);
|
const fixture = TestBed.createComponent(AppComponent);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
const compiled = fixture.nativeElement as HTMLElement;
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, frontend');
|
expect(compiled.querySelector('h1')?.textContent).toContain(
|
||||||
|
'Hello, frontend',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -3,9 +3,9 @@ import { HomeComponent } from './routes/home/home.component';
|
|||||||
import { PostComponent } from './routes/post/post.component';
|
import { PostComponent } from './routes/post/post.component';
|
||||||
import { DashboardComponent } from './routes/dashboard/dashboard.component';
|
import { DashboardComponent } from './routes/dashboard/dashboard.component';
|
||||||
import { LoggedInGuard } from './shared/guards/logged-in.guard';
|
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 { CreatePostComponent } from './routes/post/create-post/create-post.component';
|
||||||
import { UpdatePostComponent } from './routes/post/update-post/update-post.component';
|
import { UpdatePostComponent } from './routes/post/update-post/update-post.component';
|
||||||
|
import { AccountComponent } from './routes/dashboard/account/account.component';
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{ path: '', component: HomeComponent },
|
{ path: '', component: HomeComponent },
|
||||||
@ -22,8 +22,11 @@ export const routes: Routes = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'dashboard',
|
path: 'dashboard',
|
||||||
component: DashboardComponent,
|
|
||||||
canActivate: [LoggedInGuard],
|
canActivate: [LoggedInGuard],
|
||||||
|
children: [
|
||||||
|
{ path: 'account', component: AccountComponent },
|
||||||
|
{ path: '', component: DashboardComponent },
|
||||||
|
],
|
||||||
|
data: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
{ path: 'tst', component: PostEditorComponent },
|
|
||||||
];
|
];
|
||||||
|
8
web/src/app/components/button/button.component.html
Normal file
8
web/src/app/components/button/button.component.html
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<button
|
||||||
|
(click)="(click)"
|
||||||
|
[disabled]="disabled"
|
||||||
|
[class]="getStyle()"
|
||||||
|
[routerLink]="link ? link : null"
|
||||||
|
>
|
||||||
|
<ng-content />
|
||||||
|
</button>
|
23
web/src/app/components/button/button.component.spec.ts
Normal file
23
web/src/app/components/button/button.component.spec.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { ButtonComponent } from './button.component';
|
||||||
|
|
||||||
|
describe('ButtonComponent', () => {
|
||||||
|
let component: ButtonComponent;
|
||||||
|
let fixture: ComponentFixture<ButtonComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [ButtonComponent]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ButtonComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
33
web/src/app/components/button/button.component.ts
Normal file
33
web/src/app/components/button/button.component.ts
Normal file
@ -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<Event>();
|
||||||
|
@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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,21 @@
|
|||||||
|
<form [formGroup]="form" (ngSubmit)="submit()" class="grid grid-cols-3 gap-5">
|
||||||
|
<label>Password:</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
formControlName="password"
|
||||||
|
class="p-1 border-2 rounded-md col-span-2"
|
||||||
|
/>
|
||||||
|
@if (form.invalid && this.form.touched) {
|
||||||
|
<p class="col-span-3 text-red-600 italic">
|
||||||
|
Password must be at least 6 chars.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
required
|
||||||
|
[class.bg-gray-500]="form.invalid"
|
||||||
|
class="col-start-3 col-span-1 bg-blue-300 p-2 rounded-md drop-shadow-md hover:drop-shadow-xl active:bg-blue-600 disabled:bg-gray-500"
|
||||||
|
>
|
||||||
|
Change Password
|
||||||
|
</button>
|
||||||
|
</form>
|
@ -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<ChangePasswordComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [ChangePasswordComponent]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ChangePasswordComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -1,6 +1,6 @@
|
|||||||
<div
|
<div
|
||||||
*ngIf="open === true"
|
*ngIf="open === true"
|
||||||
class="fixed flex top-0 justify-center items-center w-screen h-screen z-50 backdrop-blur-md bg-black/25"
|
class="fixed flex top-0 right-0 left-0 justify-center items-center w-screen h-screen z-50 backdrop-blur-md bg-black/25"
|
||||||
>
|
>
|
||||||
<div class="p-10 bg-white drop-shadow-md rounded-md">
|
<div class="p-10 bg-white drop-shadow-md rounded-md">
|
||||||
<button (click)="toggleOpen()" class="absolute top-0 right-1 p-3">X</button>
|
<button (click)="toggleOpen()" class="absolute top-0 right-1 p-3">X</button>
|
||||||
|
@ -0,0 +1,9 @@
|
|||||||
|
<h1 class="text-2xl">Actions</h1>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5 mt-5">
|
||||||
|
<app-button (click)="auth.logout()">logout</app-button>
|
||||||
|
<app-button (click)="changePW()">Change Password</app-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<app-modal [(open)]="changePWOpen">
|
||||||
|
<app-change-password (done)="changePWOpen = false" />
|
||||||
|
</app-modal>
|
@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { AccountComponent } from './account.component';
|
||||||
|
|
||||||
|
describe('AccountComponent', () => {
|
||||||
|
let component: AccountComponent;
|
||||||
|
let fixture: ComponentFixture<AccountComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [AccountComponent]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(AccountComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
19
web/src/app/routes/dashboard/account/account.component.ts
Normal file
19
web/src/app/routes/dashboard/account/account.component.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -18,17 +18,18 @@
|
|||||||
<p class="col-start-1 col-span-4 row-start-2 row-span-2">
|
<p class="col-start-1 col-span-4 row-start-2 row-span-2">
|
||||||
<strong>TL;DR; </strong>{{ post.tldr }}
|
<strong>TL;DR; </strong>{{ post.tldr }}
|
||||||
</p>
|
</p>
|
||||||
<a
|
<app-button
|
||||||
[routerLink]="`/post/${post.id}/edit`"
|
[link]="`/post/${post.id}/edit`"
|
||||||
class="col-start-5 row-start-1 bg-amber-400 rounded-full p-1 px-5 hover:bg-amber-500 active:bg-amber-600 transition-colors text-center"
|
class="col-start-5 row-start-1"
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</a>
|
</app-button>
|
||||||
<button
|
<app-button
|
||||||
(click)="delete(post.id)"
|
(click)="delete(post.id)"
|
||||||
class="col-start-5 row-start-3 bg-orange-400 rounded-full p-1 px-5 hover:bg-amber-500 active:bg-orange-600 transition-colors"
|
class="col-start-5 row-start-3"
|
||||||
|
color="orange"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</app-button>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
import { Component, effect, inject } from '@angular/core';
|
import { Component, effect, inject } from '@angular/core';
|
||||||
import { PostEditorComponent } from '../../components/post-editor/post-editor.component';
|
|
||||||
import { NgFor } from '@angular/common';
|
import { NgFor } from '@angular/common';
|
||||||
import { PostsService } from '../../shared/services/posts.service';
|
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({
|
@Component({
|
||||||
selector: 'app-admin',
|
selector: 'app-admin',
|
||||||
imports: [NgFor, RouterLink],
|
imports: [NgFor, RouterLink, ButtonComponent],
|
||||||
standalone: true,
|
standalone: true,
|
||||||
templateUrl: './dashboard.component.html',
|
templateUrl: './dashboard.component.html',
|
||||||
})
|
})
|
||||||
|
@ -8,6 +8,7 @@ import {
|
|||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { LoginResponse, User, Claims } from '../interfaces/auth';
|
import { LoginResponse, User, Claims } from '../interfaces/auth';
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
|
import { ActivatedRouteSnapshot, Router } from '@angular/router';
|
||||||
|
|
||||||
const JWT_KEY = 'token';
|
const JWT_KEY = 'token';
|
||||||
|
|
||||||
@ -16,6 +17,7 @@ const JWT_KEY = 'token';
|
|||||||
})
|
})
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
|
private router = inject(Router);
|
||||||
jwt: WritableSignal<string | null> = signal(null);
|
jwt: WritableSignal<string | null> = signal(null);
|
||||||
claims: WritableSignal<Claims | null> = signal(null);
|
claims: WritableSignal<Claims | null> = signal(null);
|
||||||
timeout: any | null = null;
|
timeout: any | null = null;
|
||||||
@ -73,12 +75,35 @@ export class AuthService {
|
|||||||
|
|
||||||
login(user: User) {
|
login(user: User) {
|
||||||
this.http
|
this.http
|
||||||
.post<LoginResponse>(`${environment.apiRoot}/login`, user)
|
.post<LoginResponse>(`${environment.apiRoot}/auth/login`, user)
|
||||||
.subscribe((res) => this.jwt.set(res.token));
|
.subscribe((res) => this.jwt.set(res.token));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
signup(user: User) {
|
||||||
|
this.http
|
||||||
|
.post<LoginResponse>(`${environment.apiRoot}/auth/signup`, user)
|
||||||
|
.subscribe((res) => this.jwt.set(res.token));
|
||||||
|
}
|
||||||
|
|
||||||
|
changePassword(password: string): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
this.http
|
||||||
|
.put(`${environment.apiRoot}/auth/password`, { password: password })
|
||||||
|
.subscribe({
|
||||||
|
complete: () => resolve(true),
|
||||||
|
error: () => resolve(false),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
logout() {
|
logout() {
|
||||||
this.http.delete(`${environment.apiRoot}/logout`).subscribe(() => {
|
this.http.delete(`${environment.apiRoot}/auth/logout`).subscribe(() => {
|
||||||
this.jwt.set(null);
|
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);
|
const jwt = parseJwt(token);
|
||||||
return new Date(jwt.exp * 1000);
|
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));
|
||||||
|
}
|
||||||
|
Reference in New Issue
Block a user