add user authentication
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1 +1,2 @@
|
||||
/*.db
|
||||
.env
|
||||
|
||||
@@ -3,6 +3,10 @@ module archive.local
|
||||
go 1.25.4
|
||||
|
||||
require (
|
||||
github.com/jmoiron/sqlx v1.4.0 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.32 // indirect
|
||||
github.com/jmoiron/sqlx v1.4.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/mattn/go-sqlite3 v1.14.32
|
||||
golang.org/x/crypto v0.46.0
|
||||
)
|
||||
|
||||
require github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
||||
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
|
||||
168
api/main.go
168
api/main.go
@@ -1,12 +1,18 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/joho/godotenv"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
@@ -15,6 +21,76 @@ var (
|
||||
addr = flag.String("addr", ":5000", "http server address")
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID int64 `json:"id" db:"id"`
|
||||
Email string `json:"email" db:"email"`
|
||||
Password string `json:"-" db:"password"`
|
||||
CreatedAt string `json:"created_at" db:"created_at"`
|
||||
UpdatedAt string `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
func readJSON(r *http.Request, s any) error {
|
||||
return json.NewDecoder(r.Body).Decode(s)
|
||||
}
|
||||
|
||||
func sendJSON(w http.ResponseWriter, data any, status int) error {
|
||||
w.WriteHeader(status)
|
||||
return json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
func sendApiError(w http.ResponseWriter, msg string, err error, status int) {
|
||||
var e struct {
|
||||
Message string `json:"message"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
e.Message = msg
|
||||
if err != nil {
|
||||
e.Error = err.Error()
|
||||
}
|
||||
sendJSON(w, e, status)
|
||||
}
|
||||
|
||||
var jwtSecretKey []byte
|
||||
|
||||
func init() {
|
||||
if err := godotenv.Load(); err != nil {
|
||||
panic("couldn't parse env")
|
||||
}
|
||||
|
||||
s := os.Getenv("JWT_SECRET")
|
||||
if s == "" {
|
||||
panic("JWT_SECRET env doesn't exist")
|
||||
}
|
||||
jwtSecretKey = []byte(s)
|
||||
}
|
||||
|
||||
func generateAccessToken(userId int64) (string, error) {
|
||||
exp := time.Now().Add(time.Hour * 24 * 7)
|
||||
claims := jwt.MapClaims{
|
||||
"id": userId,
|
||||
"exp": exp.Unix(),
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(jwtSecretKey)
|
||||
}
|
||||
|
||||
type AuthResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
|
||||
func handler(mux *http.ServeMux) http.HandlerFunc {
|
||||
fmt.Printf("starting http server at %s\n", *addr)
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "http://localhost:3000")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization,Content-Type,Content-Length")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PATCH,DELETE,OPTIONS")
|
||||
|
||||
mux.ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
@@ -30,8 +106,96 @@ func main() {
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
fmt.Printf("starting http server at %s\n", *addr)
|
||||
if err := http.ListenAndServe(*addr, mux); err != nil {
|
||||
mux.HandleFunc("OPTIONS /", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
fmt.Fprintf(w, "ok")
|
||||
})
|
||||
|
||||
mux.HandleFunc("POST /auth/register", func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
sendApiError(w, "couldn't parse body", err, 500)
|
||||
return
|
||||
}
|
||||
|
||||
if body.Email == "" || body.Password == "" {
|
||||
sendApiError(w, "invalid request", nil, 400)
|
||||
return
|
||||
}
|
||||
|
||||
p, err := bcrypt.GenerateFromPassword([]byte(body.Password), 10)
|
||||
if err != nil {
|
||||
sendApiError(w, "couldn't hash password", err, 500)
|
||||
return
|
||||
}
|
||||
|
||||
res, err := db.Exec("INSERT INTO users (email, password) VALUES (?, ?)", body.Email, string(p))
|
||||
if err != nil {
|
||||
sendApiError(w, "couldn't create user", err, 500)
|
||||
return
|
||||
}
|
||||
_ = res
|
||||
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
sendApiError(w, "couldn't get user id", err, 500)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := generateAccessToken(id)
|
||||
if err != nil {
|
||||
sendApiError(w, "couldn't generate token", err, 500)
|
||||
return
|
||||
}
|
||||
|
||||
sendJSON(w, AuthResponse{token}, 201)
|
||||
})
|
||||
|
||||
mux.HandleFunc("POST /auth/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
sendApiError(w, "couldn't parse body", err, 500)
|
||||
return
|
||||
}
|
||||
|
||||
if body.Email == "" || body.Password == "" {
|
||||
sendApiError(w, "invalid request", nil, 400)
|
||||
return
|
||||
}
|
||||
|
||||
row := db.QueryRowx("SELECT * FROM users WHERE email = ?", body.Email)
|
||||
if row.Err() != nil {
|
||||
sendApiError(w, "couldn't find user", err, 404)
|
||||
return
|
||||
}
|
||||
|
||||
var user User
|
||||
if err := row.StructScan(&user); err != nil {
|
||||
sendApiError(w, "couldn't find user", err, 404)
|
||||
return
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(body.Password)); err != nil {
|
||||
sendApiError(w, "invalid password", nil, 400)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := generateAccessToken(user.ID)
|
||||
if err != nil {
|
||||
sendApiError(w, "couldn't generate token", err, 500)
|
||||
return
|
||||
}
|
||||
|
||||
sendJSON(w, AuthResponse{token}, 200)
|
||||
})
|
||||
|
||||
if err := http.ListenAndServe(*addr, handler(mux)); err != nil {
|
||||
log.Fatalf("failed to start http server: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
4
go.work.sum
Normal file
4
go.work.sum
Normal file
@@ -0,0 +1,4 @@
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
33
web/bun.lock
33
web/bun.lock
@@ -5,9 +5,14 @@
|
||||
"": {
|
||||
"name": "web",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
"axios": "^1.13.2",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.19",
|
||||
"next": "16.0.10",
|
||||
"react": "19.2.1",
|
||||
"react-dom": "19.2.1",
|
||||
"react-hook-form": "^7.68.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
@@ -213,6 +218,10 @@
|
||||
|
||||
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.18", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "postcss": "^8.4.41", "tailwindcss": "4.1.18" } }, "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g=="],
|
||||
|
||||
"@tanstack/query-core": ["@tanstack/query-core@5.90.12", "", {}, "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg=="],
|
||||
|
||||
"@tanstack/react-query": ["@tanstack/react-query@5.90.12", "", { "dependencies": { "@tanstack/query-core": "5.90.12" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg=="],
|
||||
|
||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
@@ -317,10 +326,14 @@
|
||||
|
||||
"async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
|
||||
|
||||
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||
|
||||
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
|
||||
|
||||
"axe-core": ["axe-core@4.11.0", "", {}, "sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ=="],
|
||||
|
||||
"axios": ["axios@1.13.2", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA=="],
|
||||
|
||||
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
|
||||
|
||||
"babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="],
|
||||
@@ -349,10 +362,14 @@
|
||||
|
||||
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||
|
||||
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||
|
||||
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
|
||||
|
||||
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
@@ -369,6 +386,8 @@
|
||||
|
||||
"data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="],
|
||||
|
||||
"dayjs": ["dayjs@1.11.19", "", {}, "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
||||
@@ -377,6 +396,8 @@
|
||||
|
||||
"define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="],
|
||||
|
||||
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
|
||||
@@ -463,8 +484,12 @@
|
||||
|
||||
"flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
|
||||
|
||||
"follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
|
||||
|
||||
"for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
|
||||
|
||||
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"function.prototype.name": ["function.prototype.name@1.1.8", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="],
|
||||
@@ -643,6 +668,10 @@
|
||||
|
||||
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
|
||||
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
|
||||
|
||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||
@@ -703,6 +732,8 @@
|
||||
|
||||
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
|
||||
|
||||
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||
@@ -711,6 +742,8 @@
|
||||
|
||||
"react-dom": ["react-dom@19.2.1", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.1" } }, "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg=="],
|
||||
|
||||
"react-hook-form": ["react-hook-form@7.68.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-oNN3fjrZ/Xo40SWlHf1yCjlMK417JxoSJVUXQjGdvdRCU07NTFei1i1f8ApUAts+IVh14e4EdakeLEA+BEAs/Q=="],
|
||||
|
||||
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||
|
||||
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
|
||||
|
||||
@@ -9,9 +9,14 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
"axios": "^1.13.2",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.19",
|
||||
"next": "16.0.10",
|
||||
"react": "19.2.1",
|
||||
"react-dom": "19.2.1"
|
||||
"react-dom": "19.2.1",
|
||||
"react-hook-form": "^7.68.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
||||
19
web/src/api/auth/useLoginMutation.ts
Normal file
19
web/src/api/auth/useLoginMutation.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { $axios } from "@/lib/axios";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
export type LoginData = {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export const useLoginMutation = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (data: LoginData) => {
|
||||
const resp = await $axios.post<{ access_token: string }>(
|
||||
"/auth/login",
|
||||
data,
|
||||
);
|
||||
return resp.data;
|
||||
},
|
||||
});
|
||||
};
|
||||
19
web/src/api/auth/useRegisterMutation.ts
Normal file
19
web/src/api/auth/useRegisterMutation.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { $axios } from "@/lib/axios";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
export type RegisterData = {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export const useRegisterMutation = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (data: RegisterData) => {
|
||||
const resp = await $axios.post<{ access_token: string }>(
|
||||
"/auth/register",
|
||||
data,
|
||||
);
|
||||
return resp.data;
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import { Providers } from "@/app/providers";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "archive.local",
|
||||
@@ -9,11 +11,13 @@ export const metadata: Metadata = {
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
children: ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<Providers>
|
||||
<html lang="en" className="h-full">
|
||||
<body className="h-full flex flex-col">{children}</body>
|
||||
</html>
|
||||
</Providers>
|
||||
);
|
||||
}
|
||||
|
||||
63
web/src/app/login/LoginForm.tsx
Normal file
63
web/src/app/login/LoginForm.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { LoginData, useLoginMutation } from "@/api/auth/useLoginMutation";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import dayjs from "dayjs";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
export const LoginForm = () => {
|
||||
const router = useRouter();
|
||||
const mutation = useLoginMutation();
|
||||
|
||||
const form = useForm<LoginData>({
|
||||
defaultValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit((data) => {
|
||||
mutation.mutate(data, {
|
||||
onSuccess(resp) {
|
||||
const expDate = dayjs().add(7, "days").toString();
|
||||
document.cookie = `access_token=${resp.access_token}; SameSite=None; Secure; Expires=${expDate}; Path=/`;
|
||||
router.push("/");
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
className="max-w-[500px] w-full bg-white rounded-lg p-4 shadow-2xl"
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="johndoe@gmail.com"
|
||||
label="Email"
|
||||
{...form.register("email", { required: true })}
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="******"
|
||||
label="Password"
|
||||
{...form.register("password", { required: true })}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full mt-6">
|
||||
Sign in
|
||||
</Button>
|
||||
<p className="mt-2 text-sm">
|
||||
Don't have an account? Create one here{" "}
|
||||
<Link href="/register" className="underline hover:no-underline">
|
||||
here
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
18
web/src/app/login/page.tsx
Normal file
18
web/src/app/login/page.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { LoginForm } from "@/app/login/LoginForm";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function Login() {
|
||||
const c = await cookies();
|
||||
const token = c.get("access_token");
|
||||
if (token) {
|
||||
throw redirect("/");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col justify-center items-center bg-neutral-100">
|
||||
<h2 className="mb-10 text-5xl font-medium">archive.local</h2>
|
||||
<LoginForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,14 @@
|
||||
export default function Home() {
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function Home() {
|
||||
const c = await cookies();
|
||||
const token = c.get("access_token");
|
||||
|
||||
if (!token) {
|
||||
throw redirect("/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>archive.local</h1>
|
||||
|
||||
21
web/src/app/providers.tsx
Normal file
21
web/src/app/providers.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactNode, useState } from "react";
|
||||
|
||||
export const Providers = ({ children }: { children: ReactNode }) => {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
66
web/src/app/register/RegisterForm.tsx
Normal file
66
web/src/app/register/RegisterForm.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
RegisterData,
|
||||
useRegisterMutation,
|
||||
} from "@/api/auth/useRegisterMutation";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import dayjs from "dayjs";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
export const RegisterForm = () => {
|
||||
const router = useRouter();
|
||||
const mutation = useRegisterMutation();
|
||||
|
||||
const form = useForm<RegisterData>({
|
||||
defaultValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit((data) => {
|
||||
mutation.mutate(data, {
|
||||
onSuccess(resp) {
|
||||
const expDate = dayjs().add(7, "days").toString();
|
||||
document.cookie = `access_token=${resp.access_token}; SameSite=None; Secure; Expires=${expDate}; Path=/`;
|
||||
router.push("/");
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
className="max-w-[500px] w-full bg-white rounded-lg p-4 shadow-2xl"
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="johndoe@gmail.com"
|
||||
label="Email"
|
||||
{...form.register("email", { required: true })}
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="******"
|
||||
label="Password"
|
||||
{...form.register("password", { required: true })}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full mt-6">
|
||||
Sign up
|
||||
</Button>
|
||||
<p className="mt-2 text-sm">
|
||||
Already have an account? Login{" "}
|
||||
<Link href="/login" className="underline hover:no-underline">
|
||||
here
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
18
web/src/app/register/page.tsx
Normal file
18
web/src/app/register/page.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { RegisterForm } from "@/app/register/RegisterForm";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function Register() {
|
||||
const c = await cookies();
|
||||
const token = c.get("access_token");
|
||||
if (token) {
|
||||
throw redirect("/");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col justify-center items-center bg-neutral-100">
|
||||
<h2 className="mb-10 text-5xl font-medium">archive.local</h2>
|
||||
<RegisterForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
web/src/components/ui/Button.tsx
Normal file
30
web/src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { clsx } from "clsx";
|
||||
import { forwardRef, JSX } from "react";
|
||||
|
||||
const sizeClasses = {
|
||||
medium: "py-2 px-4",
|
||||
};
|
||||
|
||||
export type ButtonProps = JSX.IntrinsicElements["button"] & {
|
||||
size?: keyof typeof sizeClasses;
|
||||
};
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ size = "medium", className, children, ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={clsx(
|
||||
"border border-neutral-800 rounded-md not-disabled:cursor-pointer not-disabled:hover:bg-neutral-800 not-disabled:hover:text-white/90 disabled:cursor-not-allowed active:scale-95 transition duration-200",
|
||||
className,
|
||||
sizeClasses[size],
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Button.displayName = "Button";
|
||||
26
web/src/components/ui/Input.tsx
Normal file
26
web/src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import clsx from "clsx";
|
||||
import { forwardRef, JSX, useId } from "react";
|
||||
|
||||
export type InputProps = JSX.IntrinsicElements["input"] & {
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ label, className, ...props }, ref) => {
|
||||
const id = useId();
|
||||
|
||||
return (
|
||||
<div className={clsx("flex flex-col gap-1", className)}>
|
||||
{label && <label htmlFor={id}>{label}</label>}
|
||||
<input
|
||||
ref={ref}
|
||||
id={id}
|
||||
className="py-1 px-2 rounded-md border border-neutral-800"
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Input.displayName = "Input";
|
||||
19
web/src/lib/axios.ts
Normal file
19
web/src/lib/axios.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import axios from "axios";
|
||||
|
||||
export const $axios = axios.create({
|
||||
baseURL: "http://localhost:5000",
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
$axios.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem("token");
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user