Compare commits

...

13 Commits

Author SHA1 Message Date
cc49ab1655 feat: verify state 2025-06-07 00:14:09 +02:00
06c60b3491 feat: verification layout 2025-06-07 00:14:03 +02:00
b584a7b07f feat: UI stepper component 2025-06-07 00:13:54 +02:00
410e420a46 feat: use dark background overlay 2025-06-07 00:13:45 +02:00
eeb0f6eac1 feat: user profile verification fields 2025-06-07 00:13:29 +02:00
fb622f918a feat: redirect to verify when required 2025-06-07 00:13:17 +02:00
a50bad417f feat: mask email util 2025-06-07 00:12:56 +02:00
c395729446 feat: register verification pages 2025-06-07 00:12:50 +02:00
eaa92d2fe4 feat: verification pages 2025-06-07 00:12:41 +02:00
a9e382d713 feat: new dark overlay img 2025-06-07 00:12:22 +02:00
974244025e feat: remove unused @emotion/react 2025-06-07 00:12:04 +02:00
ae41076673 feat: move UserDTO logic into single file 2025-06-07 00:11:46 +02:00
cc7f7f40c4 feat: add verification levels 2025-06-07 00:11:02 +02:00
23 changed files with 662 additions and 353 deletions

View File

@ -13,20 +13,6 @@ import (
"github.com/google/uuid"
)
func NewUserDTO(row *repository.User) types.UserDTO {
return types.UserDTO{
ID: row.ID,
Email: row.Email,
FullName: row.FullName,
IsAdmin: row.IsAdmin,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
LastLogin: row.LastLogin,
PhoneNumber: row.PhoneNumber,
ProfilePicture: row.ProfilePicture,
}
}
func (h *AdminHandler) GetUsers(w http.ResponseWriter, r *http.Request) {
userId, ok := util.GetRequestUserId(r.Context())
if !ok {
@ -55,7 +41,7 @@ func (h *AdminHandler) GetUsers(w http.ResponseWriter, r *http.Request) {
var items []types.UserDTO
for _, user := range users {
items = append(items, NewUserDTO(&user))
items = append(items, types.NewUserDTO(&user))
}
encoder := json.NewEncoder(w)
@ -88,7 +74,7 @@ func (h *AdminHandler) GetUser(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if err := encoder.Encode(NewUserDTO(&user)); err != nil {
if err := encoder.Encode(types.NewUserDTO(&user)); err != nil {
web.Error(w, "failed to encode user dto", http.StatusInternalServerError)
}
}

View File

@ -162,17 +162,7 @@ func (h *AuthHandler) getProfile(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(types.UserDTO{
ID: user.ID,
FullName: user.FullName,
Email: user.Email,
PhoneNumber: user.PhoneNumber,
IsAdmin: user.IsAdmin,
LastLogin: user.LastLogin,
ProfilePicture: user.ProfilePicture,
UpdatedAt: user.UpdatedAt,
CreatedAt: user.CreatedAt,
}); err != nil {
if err := json.NewEncoder(w).Encode(types.NewUserDTO(&user)); err != nil {
web.Error(w, "failed to encode user profile", http.StatusInternalServerError)
}
}

View File

@ -37,4 +37,6 @@ type User struct {
ProfilePicture *string `json:"profile_picture"`
CreatedBy *uuid.UUID `json:"created_by"`
EmailVerified bool `json:"email_verified"`
AvatarVerified bool `json:"avatar_verified"`
Verified bool `json:"verified"`
}

View File

@ -12,7 +12,7 @@ import (
)
const findAdminUsers = `-- name: FindAdminUsers :many
SELECT id, email, full_name, password_hash, is_admin, created_at, updated_at, last_login, phone_number, profile_picture, created_by, email_verified FROM users WHERE created_by = $1
SELECT id, email, full_name, password_hash, is_admin, created_at, updated_at, last_login, phone_number, profile_picture, created_by, email_verified, avatar_verified, verified FROM users WHERE created_by = $1
`
func (q *Queries) FindAdminUsers(ctx context.Context, createdBy *uuid.UUID) ([]User, error) {
@ -37,6 +37,8 @@ func (q *Queries) FindAdminUsers(ctx context.Context, createdBy *uuid.UUID) ([]U
&i.ProfilePicture,
&i.CreatedBy,
&i.EmailVerified,
&i.AvatarVerified,
&i.Verified,
); err != nil {
return nil, err
}
@ -49,7 +51,7 @@ func (q *Queries) FindAdminUsers(ctx context.Context, createdBy *uuid.UUID) ([]U
}
const findAllUsers = `-- name: FindAllUsers :many
SELECT id, email, full_name, password_hash, is_admin, created_at, updated_at, last_login, phone_number, profile_picture, created_by, email_verified FROM users
SELECT id, email, full_name, password_hash, is_admin, created_at, updated_at, last_login, phone_number, profile_picture, created_by, email_verified, avatar_verified, verified FROM users
`
func (q *Queries) FindAllUsers(ctx context.Context) ([]User, error) {
@ -74,6 +76,8 @@ func (q *Queries) FindAllUsers(ctx context.Context) ([]User, error) {
&i.ProfilePicture,
&i.CreatedBy,
&i.EmailVerified,
&i.AvatarVerified,
&i.Verified,
); err != nil {
return nil, err
}
@ -86,7 +90,7 @@ func (q *Queries) FindAllUsers(ctx context.Context) ([]User, error) {
}
const findUserEmail = `-- name: FindUserEmail :one
SELECT id, email, full_name, password_hash, is_admin, created_at, updated_at, last_login, phone_number, profile_picture, created_by, email_verified FROM users WHERE email = $1 LIMIT 1
SELECT id, email, full_name, password_hash, is_admin, created_at, updated_at, last_login, phone_number, profile_picture, created_by, email_verified, avatar_verified, verified FROM users WHERE email = $1 LIMIT 1
`
func (q *Queries) FindUserEmail(ctx context.Context, email string) (User, error) {
@ -105,12 +109,14 @@ func (q *Queries) FindUserEmail(ctx context.Context, email string) (User, error)
&i.ProfilePicture,
&i.CreatedBy,
&i.EmailVerified,
&i.AvatarVerified,
&i.Verified,
)
return i, err
}
const findUserId = `-- name: FindUserId :one
SELECT id, email, full_name, password_hash, is_admin, created_at, updated_at, last_login, phone_number, profile_picture, created_by, email_verified FROM users WHERE id = $1 LIMIT 1
SELECT id, email, full_name, password_hash, is_admin, created_at, updated_at, last_login, phone_number, profile_picture, created_by, email_verified, avatar_verified, verified FROM users WHERE id = $1 LIMIT 1
`
func (q *Queries) FindUserId(ctx context.Context, id uuid.UUID) (User, error) {
@ -129,6 +135,8 @@ func (q *Queries) FindUserId(ctx context.Context, id uuid.UUID) (User, error) {
&i.ProfilePicture,
&i.CreatedBy,
&i.EmailVerified,
&i.AvatarVerified,
&i.Verified,
)
return i, err
}

View File

@ -3,6 +3,7 @@ package types
import (
"time"
"gitea.local/admin/hspguard/internal/repository"
"github.com/google/uuid"
)
@ -16,4 +17,24 @@ type UserDTO struct {
LastLogin *time.Time `json:"last_login"`
PhoneNumber *string `json:"phone_number"`
ProfilePicture *string `json:"profile_picture"`
EmailVerified bool `json:"email_verified"`
AvatarVerified bool `json:"avatar_verified"`
Verified bool `json:"verified"`
}
func NewUserDTO(row *repository.User) UserDTO {
return UserDTO{
ID: row.ID,
Email: row.Email,
FullName: row.FullName,
IsAdmin: row.IsAdmin,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
LastLogin: row.LastLogin,
PhoneNumber: row.PhoneNumber,
ProfilePicture: row.ProfilePicture,
EmailVerified: row.EmailVerified,
AvatarVerified: row.AvatarVerified,
Verified: row.Verified,
}
}

View File

@ -0,0 +1,12 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE users
ADD COLUMN avatar_verified BOOLEAN NOT NULL DEFAULT FALSE;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE users
DROP COLUMN avatar_verified;
-- +goose StatementEnd

View File

@ -0,0 +1,12 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE users
ADD COLUMN verified BOOLEAN NOT NULL DEFAULT FALSE;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE users
DROP COLUMN verified;
-- +goose StatementEnd

339
web/package-lock.json generated
View File

@ -8,7 +8,6 @@
"name": "web",
"version": "0.0.0",
"dependencies": {
"@emotion/react": "^11.14.0",
"@tailwindcss/vite": "^4.1.7",
"axios": "^1.9.0",
"idb": "^8.0.3",
@ -58,6 +57,7 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
"integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.27.1",
@ -113,6 +113,7 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.1.tgz",
"integrity": "sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.27.1",
@ -146,6 +147,7 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz",
"integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.27.1",
@ -187,6 +189,7 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@ -196,6 +199,7 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@ -229,6 +233,7 @@
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.2.tgz",
"integrity": "sha512-QYLs8299NA7WM/bZAdp+CviYYkVoYXlDW2rzliy3chxd1PQjej7JORuMJDJXJUb9g0TT+B99EwaVLKmX+sPXWw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.27.1"
@ -272,19 +277,11 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/runtime": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.1.tgz",
"integrity": "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
"integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.27.1",
@ -299,6 +296,7 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.1.tgz",
"integrity": "sha512-ZCYtZciz1IWJB4U61UPu4KEaqyfj+r5T1Q5mqPo+IBpcG9kHv30Z0aD8LXPgC1trYa6rK0orRyAhqUgk4MjmEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.27.1",
@ -317,6 +315,7 @@
"version": "11.12.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
"integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
@ -326,6 +325,7 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.1.tgz",
"integrity": "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
@ -335,126 +335,6 @@
"node": ">=6.9.0"
}
},
"node_modules/@emotion/babel-plugin": {
"version": "11.13.5",
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
"integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==",
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.16.7",
"@babel/runtime": "^7.18.3",
"@emotion/hash": "^0.9.2",
"@emotion/memoize": "^0.9.0",
"@emotion/serialize": "^1.3.3",
"babel-plugin-macros": "^3.1.0",
"convert-source-map": "^1.5.0",
"escape-string-regexp": "^4.0.0",
"find-root": "^1.1.0",
"source-map": "^0.5.7",
"stylis": "4.2.0"
}
},
"node_modules/@emotion/babel-plugin/node_modules/convert-source-map": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
"license": "MIT"
},
"node_modules/@emotion/cache": {
"version": "11.14.0",
"resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz",
"integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==",
"license": "MIT",
"dependencies": {
"@emotion/memoize": "^0.9.0",
"@emotion/sheet": "^1.4.0",
"@emotion/utils": "^1.4.2",
"@emotion/weak-memoize": "^0.4.0",
"stylis": "4.2.0"
}
},
"node_modules/@emotion/hash": {
"version": "0.9.2",
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
"integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==",
"license": "MIT"
},
"node_modules/@emotion/memoize": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz",
"integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==",
"license": "MIT"
},
"node_modules/@emotion/react": {
"version": "11.14.0",
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
"@emotion/cache": "^11.14.0",
"@emotion/serialize": "^1.3.3",
"@emotion/use-insertion-effect-with-fallbacks": "^1.2.0",
"@emotion/utils": "^1.4.2",
"@emotion/weak-memoize": "^0.4.0",
"hoist-non-react-statics": "^3.3.1"
},
"peerDependencies": {
"react": ">=16.8.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@emotion/serialize": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz",
"integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==",
"license": "MIT",
"dependencies": {
"@emotion/hash": "^0.9.2",
"@emotion/memoize": "^0.9.0",
"@emotion/unitless": "^0.10.0",
"@emotion/utils": "^1.4.2",
"csstype": "^3.0.2"
}
},
"node_modules/@emotion/sheet": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz",
"integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==",
"license": "MIT"
},
"node_modules/@emotion/unitless": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz",
"integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==",
"license": "MIT"
},
"node_modules/@emotion/use-insertion-effect-with-fallbacks": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz",
"integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@emotion/utils": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz",
"integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==",
"license": "MIT"
},
"node_modules/@emotion/weak-memoize": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz",
"integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==",
"license": "MIT"
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz",
@ -2068,12 +1948,6 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/parse-json": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
"integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==",
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.1.4",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.4.tgz",
@ -2423,21 +2297,6 @@
"proxy-from-env": "^1.1.0"
}
},
"node_modules/babel-plugin-macros": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz",
"integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.5",
"cosmiconfig": "^7.0.0",
"resolve": "^1.19.0"
},
"engines": {
"node": ">=10",
"npm": ">=6"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@ -2519,6 +2378,7 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
"integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@ -2642,31 +2502,6 @@
"node": ">=18"
}
},
"node_modules/cosmiconfig": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
"integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==",
"license": "MIT",
"dependencies": {
"@types/parse-json": "^4.0.0",
"import-fresh": "^3.2.1",
"parse-json": "^5.0.0",
"path-type": "^4.0.0",
"yaml": "^1.10.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/cosmiconfig/node_modules/yaml": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
"license": "ISC",
"engines": {
"node": ">= 6"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -2686,12 +2521,14 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@ -2768,15 +2605,6 @@
"node": ">=10.13.0"
}
},
"node_modules/error-ex": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
"integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==",
"license": "MIT",
"dependencies": {
"is-arrayish": "^0.2.1"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@ -2876,6 +2704,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@ -3149,12 +2978,6 @@
"node": ">=8"
}
},
"node_modules/find-root": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
"integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==",
"license": "MIT"
},
"node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@ -3398,15 +3221,6 @@
"node": ">= 0.4"
}
},
"node_modules/hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
"license": "BSD-3-Clause",
"dependencies": {
"react-is": "^16.7.0"
}
},
"node_modules/idb": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz",
@ -3434,6 +3248,7 @@
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
"integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"parent-module": "^1.0.0",
@ -3463,27 +3278,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/is-arrayish": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
"integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
"license": "MIT"
},
"node_modules/is-core-module": {
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
"integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
"license": "MIT",
"dependencies": {
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@ -3537,6 +3331,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
@ -3556,6 +3351,7 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
"dev": true,
"license": "MIT",
"bin": {
"jsesc": "bin/jsesc"
@ -3571,12 +3367,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/json-parse-even-better-errors": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
"license": "MIT"
},
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@ -3865,12 +3655,6 @@
"node": ">=8"
}
},
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"license": "MIT"
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@ -4029,6 +3813,7 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
@ -4134,6 +3919,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
"integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
"dev": true,
"license": "MIT",
"dependencies": {
"callsites": "^3.0.0"
@ -4142,24 +3928,6 @@
"node": ">=6"
}
},
"node_modules/parse-json": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
"integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.0.0",
"error-ex": "^1.3.1",
"json-parse-even-better-errors": "^2.3.0",
"lines-and-columns": "^1.1.6"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/path": {
"version": "0.12.7",
"resolved": "https://registry.npmjs.org/path/-/path-0.12.7.tgz",
@ -4191,21 +3959,6 @@
"node": ">=8"
}
},
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"license": "MIT"
},
"node_modules/path-type": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -4372,12 +4125,6 @@
"react": "*"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-jwt": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/react-jwt/-/react-jwt-1.3.0.tgz",
@ -4439,30 +4186,11 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/resolve": {
"version": "1.22.10",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
"integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==",
"license": "MIT",
"dependencies": {
"is-core-module": "^2.16.0",
"path-parse": "^1.0.7",
"supports-preserve-symlinks-flag": "^1.0.0"
},
"bin": {
"resolve": "bin/resolve"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
@ -4608,15 +4336,6 @@
"node": ">=8"
}
},
"node_modules/source-map": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
"integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -4639,12 +4358,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/stylis": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz",
"integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==",
"license": "MIT"
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@ -4658,18 +4371,6 @@
"node": ">=8"
}
},
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/tailwindcss": {
"version": "4.1.7",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.7.tgz",

View File

@ -11,7 +11,6 @@
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@tailwindcss/vite": "^4.1.7",
"axios": "^1.9.0",
"idb": "^8.0.3",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 416 KiB

After

Width:  |  Height:  |  Size: 59 KiB

View File

@ -19,6 +19,12 @@ import ApiServiceEditPage from "./pages/Admin/ApiServices/Update";
import AdminUsersPage from "./pages/Admin/Users";
import AdminViewUserPage from "./pages/Admin/Users/View";
import AdminCreateUserPage from "./pages/Admin/Users/Create";
import VerificationLayout from "./layout/VerificationLayout";
import VerifyStartPage from "./pages/Verify";
import VerifyEmailPage from "./pages/Verify/Email";
import VerifyEmailOtpPage from "./pages/Verify/Email/OTP";
import VerifyAvatarPage from "./pages/Verify/Avatar";
import VerifyReviewPage from "./pages/Verify/Review";
const router = createBrowserRouter([
{
@ -77,6 +83,38 @@ const router = createBrowserRouter([
},
],
},
{
path: "/verify",
element: <AuthLayout />,
children: [
{
path: "/verify",
element: <VerificationLayout />,
children: [
{
index: true,
element: <VerifyStartPage />,
},
{
path: "email",
element: <VerifyEmailPage />,
},
{
path: "email/otp",
element: <VerifyEmailOtpPage />,
},
{
path: "avatar",
element: <VerifyAvatarPage />,
},
{
path: "review",
element: <VerifyReviewPage />,
},
],
},
],
},
{
path: "/auth",
element: <AuthLayout />,

View File

@ -0,0 +1,93 @@
import React, { useMemo } from "react";
type Step = {
id: string;
label: string;
description?: string;
icon?: React.ReactNode;
};
type StepperProps = {
steps: Step[];
currentStep: string;
};
export const Stepper: React.FC<StepperProps> = ({ steps, currentStep }) => {
const stepIndex = useMemo(
() => steps.findIndex((s) => s.id === currentStep),
[currentStep, steps],
);
const percent = useMemo(() => {
return steps.length === 1
? 100
: Math.round((stepIndex / (steps.length - 1)) * 100);
}, [stepIndex, steps.length]);
return (
<div className="flex flex-col sm:flex-row sm:items-center w-full max-w-2xl mx-auto mb-5 sm:mb-8 gap-5 relative">
{steps.map((step, idx) => (
<>
<div
key={idx}
className={`sm:flex p-4 pb-0 sm:p-0 flex-1 items-center ${idx < stepIndex ? "opacity-70" : ""} ${idx === stepIndex ? "flex" : "hidden"}`}
>
{/* Step circle */}
<div
className={`relative z-10 flex items-center justify-center w-12 h-12 min-w-12 sm:w-10 sm:h-10 sm:min-w-10 rounded-full
${
idx < stepIndex
? "bg-blue-400 text-white"
: idx === stepIndex
? "bg-blue-600 text-white"
: "bg-gray-100 dark:bg-gray-800/60 text-gray-500"
}
`}
>
{idx < stepIndex ? (
// Check icon for completed steps
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
strokeWidth={3}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5 13l4 4L19 7"
/>
</svg>
) : (
(step.icon ?? <span className="font-bold">{idx + 1}</span>)
)}
</div>
{/* Step label */}
<div className="flex flex-col ml-2 mr-2 sm:ml-4 sm:mr-4">
<span className="text-base text-gray-700 dark:text-gray-200 sm:text-sm font-medium">
{step.label}
</span>
{step.description && (
<span className="text-sm sm:text-xs text-gray-500 dark:text-gray-400">
{step.description}
</span>
)}
</div>
{/* {idx < steps.length - 1 && (
<div className="flex-1 h-1 mx-2 min-w sm:mx-4 rounded bg-gray-300 dark:bg-gray-600" />
)} */}
</div>
</>
))}
<div className="sm:hidden relative h-1 w-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
<div
className="absolute left-0 top-0 h-full bg-blue-500 transition-all ease-in duration-500"
style={{ width: `${percent}%` }}
/>
</div>
</div>
);
};

View File

@ -31,6 +31,8 @@ const AuthLayout = () => {
const authenticate = useAuth((state) => state.authenticate);
const hasAuthenticated = useAuth((state) => state.hasAuthenticated);
const authProfile = useAuth((s) => s.profile);
const signInRequired = useAuth((state) => state.signInRequired);
const location = useLocation();
@ -136,6 +138,14 @@ const AuthLayout = () => {
);
}
if (
!signInRequired &&
authProfile?.email_verified === false &&
!location.pathname.startsWith("/verify")
) {
return <Navigate to="/verify" />;
}
return (
<BackgroundLayout>
<Outlet />

View File

@ -6,8 +6,9 @@ export interface IBackgroundLayoutProps {
const BackgroundLayout: FC<IBackgroundLayoutProps> = ({ children }) => {
return (
// <div className="relative min-h-screen bg-cover bg-center bg-white dark:bg-black bg-[url(/overlay.jpg)] dark:bg-[url(/dark-overlay.jpg)]">
<div className="relative min-h-screen bg-[url(/overlay.jpg)] bg-[#f8f9fb] dark:bg-gradient-to-br from-[#101112] to-[#041758]">
// <div className="relative min-h-screen bg-[url(/overlay.jpg)] bg-[#f8f9fb] dark:bg-gradient-to-br from-[#101112] to-[#041758]">
// <div className="relative min-h-screen bg-cover bg-center bg-[url(/overlay.jpg)] bg-[#f8f9fb] dark:bg-[#101112] dark:bg-[url(/background-dark.png)]">
<div className="relative min-h-screen bg-cover bg-center bg-[#f8f9fb] dark:bg-black bg-[url(/overlay.jpg)] dark:bg-[url(/dark-overlay.jpg)]">
{children}
</div>
);

View File

@ -0,0 +1,51 @@
import { Stepper } from "@/components/ui/stepper";
import { useAuth } from "@/store/auth";
import { useVerify } from "@/store/verify";
import { Eye, MailCheck, ScanFace } from "lucide-react";
import { useEffect, type FC } from "react";
import { Outlet, useLocation } from "react-router";
const steps = [
{
id: "email",
icon: <MailCheck size={18} />,
label: "Verify Email",
description: "Confirm your address",
},
{
id: "avatar",
icon: <ScanFace size={20} />,
label: "Profile Picture",
description: "Add profile image",
},
{
id: "review",
icon: <Eye size={20} />,
label: "Done",
description: "Review & Quit",
},
];
const VerificationLayout: FC = () => {
const location = useLocation();
const profile = useAuth((s) => s.profile);
const step = useVerify((s) => s.step);
const loadStep = useVerify((s) => s.loadStep);
useEffect(() => {
if (profile) loadStep(profile);
}, [loadStep, profile]);
return (
<div className="w-full h-screen max-h-screen overflow-y-auto flex flex-col items-center sm:justify-center bg-white/50 dark:bg-black/50">
<div className="w-full h-full sm:w-auto sm:h-auto">
{location.pathname.replace(/\/$/i, "") !== "/verify" &&
step != null && <Stepper steps={steps} currentStep={step} />}
<Outlet />
</div>
</div>
);
};
export default VerificationLayout;

View File

@ -0,0 +1,180 @@
import { Button } from "@/components/ui/button";
import { useAuth } from "@/store/auth";
import { User } from "lucide-react";
import { useCallback, useEffect, useRef, useState, type FC } from "react";
import { Link } from "react-router";
const VerifyAvatarPage: FC = () => {
const profile = useAuth((s) => s.profile);
const videoRef = useRef<HTMLVideoElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const [stream, setStream] = useState<MediaStream | null>(null);
const [avatar, setAvatar] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [takingPicture, setTakingPicture] = useState(false);
const fileInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (profile?.profile_picture) setAvatar(profile.profile_picture);
}, [profile?.profile_picture]);
// Request camera stream
useEffect(() => {
if (!takingPicture) return;
if (!navigator.mediaDevices?.getUserMedia) {
setError("Camera not supported on this device/browser.");
return;
}
navigator.mediaDevices
.getUserMedia({ video: true })
.then((mediaStream) => {
setStream(mediaStream);
setError(null);
if (videoRef.current) {
videoRef.current.srcObject = mediaStream;
}
})
.catch(() => setError("Unable to access camera."));
return () => {
// Clean up camera stream when component unmounts or stops taking picture
if (stream) {
stream.getTracks().forEach((track) => track.stop());
setStream(null);
}
};
// eslint-disable-next-line
}, [takingPicture]);
const handleTakePicture = useCallback(() => {
setTakingPicture(true);
}, []);
const handleCapture = useCallback(() => {
if (!videoRef.current || !canvasRef.current) return;
const context = canvasRef.current.getContext("2d");
if (!context) return;
// Set canvas size to video size
canvasRef.current.width = videoRef.current.videoWidth;
canvasRef.current.height = videoRef.current.videoHeight;
context.drawImage(
videoRef.current,
0,
0,
videoRef.current.videoWidth,
videoRef.current.videoHeight,
);
const imageData = canvasRef.current.toDataURL("image/png");
setAvatar(imageData);
setTakingPicture(false);
}, []);
const handleSelectFromDevice = useCallback(() => {
fileInputRef.current?.click();
}, []);
const handleFileChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (!file.type.startsWith("image/")) {
setError("Please select an image file.");
return;
}
const reader = new FileReader();
reader.onload = (event) => {
setAvatar(event.target?.result as string);
setError(null);
};
reader.readAsDataURL(file);
},
[],
);
const handleRetake = useCallback(() => {
setAvatar(null);
setTakingPicture(false);
}, []);
return (
<div className="w-full sm:max-w-sm mx-auto p-4">
<div className="flex flex-col gap-2 w-full max-w-xs mx-auto">
<h1 className="text-xl font-medium dark:text-gray-200">
Profile Picture
</h1>
<p className="dark:text-gray-400 mb-6">
Please take a photo of yourself for your avatar in order to continue.
</p>
<div className="relative w-48 h-48 mx-auto rounded-full border-4 border-blue-500 overflow-hidden bg-gray-100 dark:bg-gray-800 flex items-center justify-center mb-6">
{takingPicture ? (
<video
ref={videoRef}
autoPlay
playsInline
className="w-full h-full object-cover rounded-lg bg-black"
/>
) : avatar ? (
<img
src={avatar}
alt="Avatar"
className="w-full h-full object-cover"
/>
) : (
<span className="text-4xl text-gray-400">
<User size={48} />
</span>
)}
</div>
{error && <div className="text-red-500 text-center">{error}</div>}
{/* File input (hidden) */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFileChange}
/>
{!avatar && !takingPicture && (
<Button onClick={handleTakePicture}>Take Photo</Button>
)}
{!avatar && !takingPicture && (
<Button variant="outlined" onClick={handleSelectFromDevice}>
Pick from Device
</Button>
)}
{takingPicture && (
<div className="flex flex-col items-center gap-2 w-full">
<Button onClick={handleCapture}>Capture</Button>
<Button variant="outlined" onClick={() => setTakingPicture(false)}>
Cancel
</Button>
</div>
)}
{/* Hidden canvas for snapshot */}
<canvas ref={canvasRef} style={{ display: "none" }} />
{avatar && (
<>
<Link to="/verify/review" className="w-full">
<Button className="w-full">Next</Button>
</Link>
<Button
className="border-yellow-500 text-yellow-500 hover:border-yellow-600 hover:text-yellow-600"
variant="outlined"
onClick={handleRetake}
>
Retake/Choose Another
</Button>
</>
)}
</div>
</div>
);
};
export default VerifyAvatarPage;

View File

@ -0,0 +1,24 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { type FC } from "react";
import { Link } from "react-router";
const VerifyEmailOtpPage: FC = () => {
return (
<div className="flex flex-col items-stretch gap-2 max-w-sm mx-auto p-4">
<h1 className="text-xl font-medium dark:text-gray-200">
OTP Verification
</h1>
<p className="text-sm dark:text-gray-400">
We've sent you verification code on your email address, please open your
mailbox and enter the verification code in order to continue.
</p>
<Input placeholder="Enter OTP" />
<Link to="/verify/avatar" className="w-full">
<Button className="mt-3 w-full">Verify</Button>
</Link>
</div>
);
};
export default VerifyEmailOtpPage;

View File

@ -0,0 +1,49 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useAuth } from "@/store/auth";
import maskEmail from "@/util/maskEmail";
import { useCallback, useMemo, useState, type FC } from "react";
import { useNavigate } from "react-router";
const VerifyEmailPage: FC = () => {
const profile = useAuth((s) => s.profile);
const navigate = useNavigate();
const [email, setEmail] = useState("");
const matches = useMemo(
() => email === profile?.email,
[email, profile?.email],
);
const handleNext = useCallback(() => {
if (matches) {
navigate("/verify/email/otp");
}
}, [matches, navigate]);
return (
<div className="flex flex-col items-stretch gap-2 sm:max-w-sm mx-auto p-4">
<h1 className="text-xl font-medium dark:text-gray-200">E-Mail Address</h1>
<p className="text-sm dark:text-gray-400">
Please fill in your email address used in this account.
</p>
<Input
value={email}
placeholder={maskEmail(profile?.email ?? "")}
onChange={(e) => {
e.preventDefault();
setEmail(e.target.value);
}}
/>
<Button
className={`mt-3 ${!matches ? "opacity-60" : ""}`}
onClick={handleNext}
disabled={!matches}
>
Next
</Button>
</div>
);
};
export default VerifyEmailPage;

View File

@ -0,0 +1,37 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import Avatar from "@/feature/Avatar";
import { useAuth } from "@/store/auth";
import type { FC } from "react";
const VerifyReviewPage: FC = () => {
const profile = useAuth((s) => s.profile);
return (
<div className="flex flex-col items-stretch gap-2 sm:max-w-sm mx-auto p-4">
<h1 className="text-xl font-medium dark:text-gray-200">
You're all setup!
</h1>
<p className="text-sm dark:text-gray-400">
You've just finished your account verification. Now you can finally
access your account and use it for home services.
</p>
<Avatar
avatarId={profile?.profile_picture ?? undefined}
iconSize={64}
className="w-48 h-48 min-w-48 mx-auto mt-4"
/>
<div className="flex flex-col items-center mb-5">
<h2 className="dark:text-gray-200 text-2xl">{profile?.full_name}</h2>
<p className="dark:text-gray-400 text-sm">{profile?.email}</p>
</div>
<Button className="mt-4">Back Home</Button>
</div>
);
};
export default VerifyReviewPage;

View File

@ -0,0 +1,36 @@
import { Button } from "@/components/ui/button";
import { useAuth } from "@/store/auth";
import { ArrowRight } from "lucide-react";
import type { FC } from "react";
import { Link } from "react-router";
const VerifyStartPage: FC = () => {
const profile = useAuth((s) => s.profile);
return (
<div className="flex flex-col items-center justify-center gap-5 w-full h-screen px-4 sm:px-0 sm:max-w-xl sm:h-auto text-center">
<img src="/icon.png" className="w-16 h-16" alt="icon" />
<h1 className="text-2xl dark:text-gray-200 font-medium">
Welcome to Home Guard!
</h1>
<p className="text-base dark:text-gray-500">
Before you can access your account, we need to first verify your email
address and setup your profile.
</p>
<p className="text-base dark:text-gray-500">
You will be prompted to upload your real picture as well.{" "}
<Link to="#" className="text-blue-500">
Learn more.
</Link>
</p>
<Link to="/verify/email">
<Button className="flex items-center gap-2">
<ArrowRight />
Start Verification
</Button>
</Link>
</div>
);
};
export default VerifyStartPage;

34
web/src/store/verify.ts Normal file
View File

@ -0,0 +1,34 @@
import { create } from "zustand";
import { useAuth } from "./auth";
import type { UserProfile } from "@/types";
export type VerifyStep = "email" | "avatar" | "review";
export interface IVerifyState {
step: VerifyStep | null;
loadStep: (profile: UserProfile) => void;
}
export const useVerify = create<IVerifyState>((set) => ({
step: null,
loadStep: (profile) => {
if (!profile.email_verified) {
set({ step: "email" });
return;
}
if (!profile.avatar_verified) {
set({ step: "avatar" });
return;
}
if (!profile.verified) {
set({ step: "review" });
return;
}
set({ step: null });
},
}));

View File

@ -2,6 +2,9 @@ export interface UserProfile {
id: string;
full_name: string;
email: string;
email_verified: boolean;
avatar_verified: boolean;
verified: boolean;
phone_number: string;
is_admin: boolean;
last_login: string;

22
web/src/util/maskEmail.ts Normal file
View File

@ -0,0 +1,22 @@
export default function maskEmail(email: string): string {
// Validate email (very basic)
if (!email.includes("@")) return email;
const [user, domain] = email.split("@");
const [domainName, ...tldParts] = domain.split(".");
// Mask user part
const maskedUser =
user.length > 2 ? `${user[0]}***${user[user.length - 1]}` : `${user[0]}***`;
// Mask domain part (except TLD)
const maskedDomain =
domainName.length > 2
? `${domainName[0]}***${domainName[domainName.length - 1]}`
: `${domainName[0]}***`;
// Join TLD
const tld = tldParts.length ? "." + tldParts.join(".") : "";
return `${maskedUser}@${maskedDomain}${tld}`;
}