Compare commits

...

267 Commits

Author SHA1 Message Date
93a5cd7c70 feat: center icon 2025-06-07 22:08:23 +02:00
951de989af fix: remove unnecessary double message 2025-06-07 22:07:38 +02:00
c5cf253a15 feat: log error when failing to put object 2025-06-07 22:07:28 +02:00
d70032e36d feat: copy redis.conf 2025-06-07 22:07:16 +02:00
445ac50537 feat: list of permissions 2025-06-07 19:50:51 +02:00
c13e564b01 feat: icon_url field in api service 2025-06-07 19:47:31 +02:00
5d3a77133d feat: fetch api service state 2025-06-07 19:47:22 +02:00
44592ebc08 feat: show api service's information 2025-06-07 19:47:12 +02:00
1b941cb0c3 fix: correct uri for fetching api service 2025-06-07 19:47:02 +02:00
1cb520c2b6 feat: expose api services fetching by client id 2025-06-07 19:46:34 +02:00
b3fdd3bc18 feat: expose icon url 2025-06-07 19:46:25 +02:00
9110db2f08 feat: api service fetch by client id api 2025-06-07 19:23:42 +02:00
5972735102 fix: separate pathname for getting api service by client id 2025-06-07 19:23:34 +02:00
14c69349cc feat: get api service by client handler and endpoint 2025-06-07 19:22:44 +02:00
3ceeab04e1 feat: add icon url to api service 2025-06-07 19:21:14 +02:00
b7a67c208f feat: pass client id by code generation 2025-06-07 19:17:48 +02:00
cee885a84d feat: remove unused access to user profile 2025-06-07 19:17:39 +02:00
4b496ea9bd feat: split claims into 2 2025-06-07 19:17:29 +02:00
570ae6ac8c feat: generate 3 tokens for api service 2025-06-07 19:17:15 +02:00
f0d3a61e7b feat: accept access to cache 2025-06-07 19:16:57 +02:00
b09567620f feat: generate auth code and save 2025-06-07 19:16:45 +02:00
2209846525 feat: check for redirect uris allowed 2025-06-07 19:16:28 +02:00
108ed61961 feat: save & get auth code cache 2025-06-07 19:16:14 +02:00
b73bfd590b feat: allow custom claims for verify 2025-06-07 19:15:59 +02:00
19d56159ba feat: pass access to cache 2025-06-07 19:15:00 +02:00
b9ccf6adac feat: require client id for code API 2025-06-07 18:46:46 +02:00
9a0870dbbc fix: let user see verify welcome message 2025-06-07 11:41:52 +02:00
70429f69a2 feat: reset verify states after completion 2025-06-07 11:17:10 +02:00
ad635008eb fix: scrolling + background + height on devices 2025-06-07 02:37:03 +02:00
eacc8fdd89 fix: no need for h-full 2025-06-07 02:21:17 +02:00
d309fb3f57 fix: better scrolling on mobile 2025-06-07 02:21:06 +02:00
f4fd993679 feat: admin default page is api-services 2025-06-07 02:20:41 +02:00
016879b53f feat: upload avatar API 2025-06-07 02:10:55 +02:00
70bba15cda feat: redirect + upload avatar store 2025-06-07 02:10:46 +02:00
57daf175ab fix: fallback to empty array 2025-06-07 02:10:35 +02:00
0817a65272 feat: final verify trigger 2025-06-07 02:10:24 +02:00
13f9da1a67 feat: handle avatar uploading on verify 2025-06-07 02:10:11 +02:00
83535acf1c feat: no users found text in table 2025-06-07 02:09:59 +02:00
441ce2daca feat: redirection after verification 2025-06-07 02:09:44 +02:00
f9848d2110 feat: set redirect in state 2025-06-07 02:09:35 +02:00
7f0511b0d4 feat: finish verification API 2025-06-07 02:09:20 +02:00
a27f2ad593 feat: verify user's avatar + full url for profile picture 2025-06-07 02:09:10 +02:00
715a984241 feat: register finish verify route 2025-06-07 02:08:52 +02:00
66e1756ade feat: finish verification handler 2025-06-07 02:08:43 +02:00
849403a137 feat: confirm OTP integration 2025-06-07 01:34:48 +02:00
8d15c9b8b2 feat: request OTP query 2025-06-07 01:34:38 +02:00
87af1834cf feat: email verify state 2025-06-07 01:34:28 +02:00
357583f54d feat: navigate to current stage of verification 2025-06-07 01:34:13 +02:00
aa6de76ded feat: redirect to verify pages 2025-06-07 01:33:50 +02:00
ab3c2d1eb0 feat: stepper improvements 2025-06-07 01:33:40 +02:00
644cf2a358 feat: email verify APIs 2025-06-07 01:33:21 +02:00
5b1ed9925d feat: verify routes 2025-06-07 01:33:14 +02:00
4071a50a37 feat: split auth routes in files 2025-06-07 01:32:56 +02:00
dd7c51efd8 feat: update user verifications 2025-06-07 01:32:37 +02:00
8902f4d187 feat: redis configuration & client 2025-06-07 01:32:22 +02:00
6666b20464 feat: go-redis pkg 2025-06-07 01:31:55 +02:00
c5f288ba1e feat: redis configuration 2025-06-07 01:31:47 +02:00
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
e2ae03f2a6 feat: use avatar directly as URI 2025-06-06 12:37:34 +02:00
9319564dea feat: image full URI in avatar 2025-06-06 12:12:03 +02:00
83e3e5a2e9 feat: new env variable for server URI 2025-06-06 12:04:25 +02:00
2b40e4e922 feat: specify user creator + list only users related to admin 2025-06-06 11:58:47 +02:00
ed33d03fda feat: find users related to specific admin repo 2025-06-06 11:58:20 +02:00
34c152a459 fix: correct uri for user creation page 2025-06-05 20:50:44 +02:00
ad09e98bba feat: create user page 2025-06-05 20:50:27 +02:00
d3fd5cba16 feat: create user state 2025-06-05 20:49:52 +02:00
64dbb4368c feat: create user API 2025-06-05 20:49:43 +02:00
cb3a6ddc58 feat: add support for new fields in user table 2025-06-05 20:49:35 +02:00
e774f415d8 feat: craete user route 2025-06-05 20:49:20 +02:00
d5a22895e7 feat: user creator and verified columns 2025-06-05 20:49:05 +02:00
9983c51e3a fix: use fetch for single item 2025-06-04 21:12:30 +02:00
6a1fc193f4 fix: double scrollbar on desktop 2025-06-04 20:07:58 +02:00
118877f727 feat: user update last login 2025-06-04 20:07:48 +02:00
7b8fe6baf2 fix: set content-type to json 2025-06-04 20:07:39 +02:00
e85b23b3e8 feat: admin view user page 2025-06-04 19:33:41 +02:00
6164b77bee fix: avatar explicit color for icon 2025-06-04 19:33:28 +02:00
8b5a5744ab feat: get single user state 2025-06-04 19:33:12 +02:00
d9e9c5ab38 feat: get single user API 2025-06-04 19:33:07 +02:00
0dcef81b59 feat: get single user endpoint 2025-06-04 19:32:56 +02:00
426b70a1de feat: AdminUsersPage implementation 2025-06-04 19:17:20 +02:00
912973cdb5 feat: admin users state 2025-06-04 19:17:08 +02:00
e4ff799f05 feat: background enhancements 2025-06-04 19:16:48 +02:00
320715f5aa feat: move api services related pages to own folder 2025-06-04 19:11:51 +02:00
a3b04b6243 fix: correct spelling for is_admin 2025-06-04 19:11:28 +02:00
f610d7480f feat: register user related pages 2025-06-04 19:11:10 +02:00
11ac92a026 feat: dynamic user based roles 2025-06-04 19:11:01 +02:00
98ae3e06e9 feat: longer live time for token + correct user based role 2025-06-04 19:10:32 +02:00
a1146ce371 feat: separate file for api services store 2025-06-04 12:49:57 +02:00
a67ec7e78c fix: rename useAdmin to useApiServices 2025-06-04 12:46:48 +02:00
9895392b50 feat: fetch users API 2025-06-04 12:46:36 +02:00
c6998f33e1 feat: move user dto outside 2025-06-04 12:46:24 +02:00
81659181e4 feat: add get users route 2025-06-04 12:33:58 +02:00
849b5935c2 fix: type overriding 2025-06-04 12:33:22 +02:00
c27d837ab0 feat: get users endpoint 2025-06-04 12:33:06 +02:00
92e9b87227 feat: better types overriding 2025-06-04 12:32:47 +02:00
243b7cce33 fix: avatars width setting 2025-06-03 13:03:02 +02:00
76d960619f feat: api service edit page 2025-06-03 12:59:05 +02:00
3e59c78287 feat: api services updated modal 2025-06-03 12:58:57 +02:00
6cd9da69ab feat: update api service state 2025-06-03 12:58:40 +02:00
29b97a87b3 feat: link to edit page 2025-06-03 12:57:36 +02:00
8bc4603274 feat: update api service API 2025-06-03 12:54:20 +02:00
cc60a1ba86 feat: register edit service page in router 2025-06-03 12:54:10 +02:00
89c7dc43e5 feat: use updateapiservice endpoint 2025-06-03 12:53:57 +02:00
95c330568d feat: UI bind api service toggling 2025-06-03 00:07:09 +02:00
900d314a95 feat: don't require view service id 2025-06-03 00:07:00 +02:00
0d8a3b1b39 feat: store toggle active api service 2025-06-03 00:05:43 +02:00
4b7396c210 feat: toggle api service API 2025-06-03 00:05:32 +02:00
d4e2cbdd4f feat: activate api service query 2025-06-03 00:05:20 +02:00
5024ac8151 feat: register toggle endpoint 2025-06-03 00:05:05 +02:00
3bf08c5933 feat: toggle api service endpoint 2025-06-03 00:04:36 +02:00
b42da50306 feat: logout on error when refreshing token 2025-06-02 23:45:03 +02:00
0efc90567b feat: integrate oauth store in authorize page 2025-06-02 23:16:11 +02:00
a5466f1b10 feat: remove use of non existent oauth provider 2025-06-02 23:16:00 +02:00
8e946cbee5 feat: parse url search params on load in authlayout 2025-06-02 23:15:49 +02:00
a3a6b5e4d7 feat: create oauth store 2025-06-02 23:15:35 +02:00
ad0a0f5626 feat: remove context use 2025-06-02 23:15:29 +02:00
2389058ddc fix: use logout in both waiting and actual refreshing for/of the token 2025-06-02 23:15:21 +02:00
ce44ef3e62 feat: protect required endpoints by oauth 2025-06-02 23:15:02 +02:00
9ee30d1e23 feat: register /authorize route 2025-06-02 20:35:03 +02:00
886d0a7f5c feat: create middleware endpoint before accessing web ui interface for
authorize
2025-06-02 20:34:36 +02:00
cfdf419460 fix: use slices.Contains 2025-06-02 20:34:20 +02:00
930e069aee feat: authorize middleware to check api service activity 2025-06-02 13:00:19 +02:00
1ef261660f feat: split oauth endpoints into files 2025-06-02 12:31:01 +02:00
5d9e5d27bf fix: correct authorize uri 2025-06-01 21:22:16 +02:00
3f8a4024ce feat: .env file is optional 2025-06-01 12:55:05 +02:00
e8a74999c3 feat: dockerfile for image building 2025-06-01 12:54:44 +02:00
dc2ce1f349 fix: finish update service endpoint 2025-06-01 08:29:30 +02:00
bce775f692 feat: modify fields for updation 2025-05-31 23:19:30 +02:00
45bce711f2 feat: move routes to apiservices.go and add new ones 2025-05-31 23:19:15 +02:00
8ab2ddbe8e feat: responsiveness 2025-05-31 18:40:44 +02:00
7f9b719b2b feat: textarea border in light mode 2025-05-31 18:40:38 +02:00
2b7f4995ef feat: better border for table in light mode 2025-05-31 18:40:24 +02:00
321f4087e1 feat: delimiter color 2025-05-31 18:40:12 +02:00
9d19b470bc feat: register pages 2025-05-31 18:40:04 +02:00
68493be36e feat: redirect to not allowed page 2025-05-31 18:39:57 +02:00
f8772f8de2 feat: not allowed + not found pages 2025-05-31 18:39:49 +02:00
d451331c66 fix: enough padding for breadcrumbs 2025-05-31 17:59:11 +02:00
485cfc2d12 feat: API for getting single api service 2025-05-31 17:58:56 +02:00
944c650ab3 feat: view api service page 2025-05-31 17:58:40 +02:00
dfc5587608 feat: get single api service route 2025-05-31 17:58:31 +02:00
96bdbfda95 feat: description field 2025-05-31 17:58:19 +02:00
05a234b7a5 feat: find api service by id 2025-05-31 17:38:29 +02:00
2f58c01c24 feat: nowrap for heading 2025-05-31 17:31:43 +02:00
e92dde20ca feat: create api service PAI 2025-05-31 17:31:33 +02:00
63437d6dc7 feat: credentials modal 2025-05-31 17:31:22 +02:00
cef9dae4d3 feat: responsive breadcrumbsd 2025-05-31 17:31:06 +02:00
0d7b1355d5 feat: portal-root 2025-05-31 17:30:52 +02:00
a213ea85d0 feat: share description in dto 2025-05-31 17:30:44 +02:00
5c43f6d72a feat: accept description and is active fields 2025-05-31 17:30:07 +02:00
e49c0bbe45 feat: sqlc generate 2025-05-31 16:31:27 +02:00
413a11ee63 feat: accept description and is active fields on api service creation 2025-05-31 16:18:36 +02:00
4a112318bd feat: api services query add description and is active 2025-05-31 16:18:19 +02:00
9897eb1f5d feat: add api service page registetr 2025-05-31 16:16:43 +02:00
3fc7ceac23 feat: change border color for sidebar 2025-05-31 16:16:31 +02:00
aa48c21466 feat: better styles for UI input 2025-05-31 16:16:14 +02:00
0ca2bb3f89 feat: bar items better check for isActive 2025-05-31 16:16:04 +02:00
cd5adcdc3f feat: use breadcrubms on api services page 2025-05-31 16:15:47 +02:00
62c90d0597 fix: scroll content without sidebar 2025-05-31 16:15:31 +02:00
665d12a828 feat: create api service page 2025-05-31 16:15:22 +02:00
4d455fd62e feat: ui breadcrumbs component 2025-05-31 16:15:14 +02:00
348aacfde2 feat: add description column to api service 2025-05-31 16:15:07 +02:00
de17870bdb fix: add weirdo NUL (windows) file to git ignore 2025-05-31 10:27:34 +02:00
54581742dc feat: add API service button 2025-05-31 10:14:22 +02:00
639575dae0 feat: background for dashboard layout 2025-05-31 10:14:09 +02:00
800e1afbe5 feat: API service type 2025-05-30 21:28:06 +02:00
8abc4396ac feat: integrate admin store in api services tab 2025-05-30 21:27:59 +02:00
70f860824c feat: .air.toml file for ai 2025-05-30 21:27:46 +02:00
3c5e31cbb2 feat: api services DTO 2025-05-30 21:27:10 +02:00
3923b428a4 feat: admin zustand store 2025-05-30 21:27:02 +02:00
5b816c6873 feat: remove default inner padding for dashboard pages 2025-05-30 21:26:43 +02:00
66edadfeda feat: make hooks folder 2025-05-30 21:26:15 +02:00
b872722e07 fix: import bar items hook 2025-05-30 21:26:05 +02:00
091218b42d feat: better sidebar 2025-05-30 21:25:55 +02:00
03697b2f67 fix: window scoped state for token refresh 2025-05-30 21:25:39 +02:00
8a28fca3d9 feat: api for fetching api services 2025-05-30 21:25:15 +02:00
1ab4113040 feat: return all fields from api_service objects 2025-05-30 21:25:03 +02:00
013f300513 feat: adjust routes and make use of middlewares in each one 2025-05-30 21:24:48 +02:00
45e31b41ca feat: install pgxpool 2025-05-30 21:24:20 +02:00
182f30f1ba feat: use pgxpool instead of row connection 2025-05-30 21:24:07 +02:00
7c97ebd84f feat: let routes decide about middlewares to be used 2025-05-30 21:23:33 +02:00
9fefe3ac71 feat: makefile adjust for windows 2025-05-30 21:23:16 +02:00
ca3006c428 feat: ignore tmp/ folder 2025-05-30 21:23:02 +02:00
51b7e6b3f9 feat: admin routes + better auth routing 2025-05-30 18:17:12 +02:00
db2cb36f54 feat: location.pathname based bar navigation + admin layout + separate
auth routes
2025-05-29 23:42:17 +02:00
78e84567c7 feat: build and watch script 2025-05-29 20:13:34 +02:00
0423b3803f feat: api services tab for admin 2025-05-29 20:13:25 +02:00
60e317b9e4 feat: auth profile integration on personal info tab 2025-05-29 20:13:17 +02:00
aa18b9f3e2 feat: admin only bar items 2025-05-29 19:56:33 +02:00
d9ca1ce2b4 feat: profile picture fetching through guard service 2025-05-29 19:51:15 +02:00
41c3dfdfe4 chore: remove unnecessary console.logs 2025-05-29 17:19:13 +02:00
725cc74102 feat: prettier integration 2025-05-29 17:17:38 +02:00
83c26bb94a feat: explicitly install idb + use react-router instead of react-router-dom 2025-05-29 17:03:44 +02:00
6be3aa07a1 feat: add favicon 2025-05-29 17:03:05 +02:00
54021c3021 fix: correct path for skipping register auth require 2025-05-29 17:02:48 +02:00
4b3a814d7e fix: don't select account after logging into it 2025-05-29 14:57:35 +02:00
dd5c59afa8 feat: handle agree ('allow') button click to select account for oauth auth 2025-05-29 14:57:20 +02:00
0723a48ab0 feat: save search params 2025-05-29 14:57:01 +02:00
ffefee930a fix: don't select session for oauth after account selection 2025-05-29 14:56:37 +02:00
a7ddd3d1ff fix: use env issuer 2025-05-29 14:56:12 +02:00
21cedeabbd fix: proper page names 2025-05-29 14:19:01 +02:00
807d7538a0 feat: proper redirect handling 2025-05-29 14:17:09 +02:00
8364a8e9ec fix: avoid flash target page show 2025-05-29 12:43:39 +02:00
56755ac531 feat: navigate to home page after login if not explicit 2025-05-29 12:35:48 +02:00
e4d83e75a0 fmt: rename fetchProfile to authenticate 2025-05-29 12:35:07 +02:00
3dd91cf238 fix: proper token refreshing BEFORE sending req 2025-05-29 12:34:52 +02:00
03d6730151 feat: react-jwt pkg 2025-05-29 12:34:37 +02:00
aa152a4127 feat: authentication integration 2025-05-28 20:51:34 +02:00
a1ed1113d9 feat: fetch profile API 2025-05-28 17:40:33 +02:00
2187c873ee feat: json resposne 2025-05-28 17:40:24 +02:00
595015f324 feat: install zustand 2025-05-28 17:40:16 +02:00
8504f9c230 feat: small sidebar button text 2025-05-28 17:19:10 +02:00
04db9b8ef2 feat: unused import 2025-05-27 21:26:45 +02:00
e983719601 feat: tab navigation 2025-05-27 21:26:29 +02:00
c5c55f72b1 feat: preview page for account management 2025-05-25 17:51:37 +02:00
c445756296 feat: no scrollbar util 2025-05-25 17:51:26 +02:00
0166e62e98 feat: ability to remove spacing 2025-05-25 17:51:19 +02:00
8e22a3ac05 fix: cfg access 2025-05-25 16:52:10 +02:00
05ee30f6db feat: register api services rotues 2025-05-25 16:43:57 +02:00
dd8c453c54 feat: hash client secret 2025-05-25 16:43:17 +02:00
52870cb541 feat: util for generating client credentials 2025-05-25 16:40:58 +02:00
14b37c2220 feat: api services routes 2025-05-25 16:40:45 +02:00
5604a824fe feat: use issuer variable as base 2025-05-25 16:26:03 +02:00
7d0ddd4d77 feat: use config issuer for everything 2025-05-25 16:24:52 +02:00
07b9b94143 feat: refactor for using app config 2025-05-25 16:22:28 +02:00
b95dcc6230 feat: rename env vars + add jwt issuer 2025-05-25 16:18:30 +02:00
e8bad71f21 feat: smart config 2025-05-25 16:18:21 +02:00
4df7561dd3 feat: sqlc api_services generated cpde 2025-05-25 15:38:59 +02:00
8f753b2561 feat: api_services queries 2025-05-25 15:38:49 +02:00
11748bb68e feat: api services table migration 2025-05-25 15:38:40 +02:00
491c9a824d feat: remove unused util 2025-05-25 14:54:24 +02:00
476b9a13d9 feat: handle session selection after logging in 2025-05-25 14:54:19 +02:00
42665fffbb feat: handle account selection 2025-05-25 14:54:03 +02:00
a157a3ec0e feat: implement select session 2025-05-25 14:53:55 +02:00
024d07fdd6 feat: oauth context select session 2025-05-25 14:53:48 +02:00
23845e25dd feat: add admin role for all users for now 2025-05-25 14:53:38 +02:00
47209c311c feat: disable watch 2025-05-25 14:53:28 +02:00
2caef38ce6 feat: jwks endpoint + rsa keys use 2025-05-25 14:53:19 +02:00
e88980e64f feat: updated rsa key gen scripts 2025-05-25 14:53:02 +02:00
159e4ad0e2 feat: update ApiClaims 2025-05-25 14:16:30 +02:00
6e2d67ad24 feat: oauth endpoints: code and token 2025-05-25 14:16:20 +02:00
d46e296ce1 feat: respond with json 2025-05-25 14:15:50 +02:00
e98806e96f feat: JWT_KID env var 2025-05-25 14:14:35 +02:00
0ab82e2503 feat: disable authorization for /token + provide access to repo + register jwks endpoint 2025-05-25 14:13:05 +02:00
34c1ce7652 feat: generate code API 2025-05-25 14:12:27 +02:00
428dc50aa1 feat: oauth auth page 2025-05-24 20:59:56 +02:00
2663264f50 feat: register oauth authorize page 2025-05-24 20:59:48 +02:00
ffba961d72 fmt: don't use curly braces 2025-05-24 20:59:40 +02:00
5a939c0771 feat: link to login page to add account 2025-05-24 20:59:25 +02:00
87916f96fd feat: use oauth provider 2025-05-24 20:59:15 +02:00
c6c03e9cb6 fix: db context import 2025-05-24 20:59:04 +02:00
6fd7171450 feat: root dark overlay 2025-05-24 20:58:54 +02:00
ae07d2d3d9 feat: refactor db context 2025-05-24 20:58:36 +02:00
65545a0d71 feat: expose host 2025-05-24 20:58:09 +02:00
d423d9ba62 feat: oauth routes 2025-05-24 20:58:04 +02:00
d64c8479f8 feat: register oauth handler 2025-05-24 20:57:57 +02:00
132 changed files with 7591 additions and 1441 deletions

63
.air.toml Normal file
View File

@ -0,0 +1,63 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"
[build]
args_bin = []
bin = "bin\\hspguard"
cmd = "make build"
delay = 1000
exclude_dir = [
"assets",
"tmp",
"vendor",
"testdata",
"dist",
"migrations",
"queries",
"scripts",
"templates",
"web",
]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
post_cmd = []
pre_cmd = []
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = false
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
silent = false
time = false
[misc]
clean_on_exit = false
[proxy]
app_port = 0
enabled = false
proxy_port = 0
[screen]
clear_on_rebuild = false
keep_scroll = true

View File

@ -1,22 +1,24 @@
PORT=3001
HOST="127.0.0.1"
GUARD_PORT=3001
GUARD_HOST="127.0.0.1"
GUARD_URI="http://localhost:3001"
DATABASE_URL="postgres://<user>:<user>@<host>:<port>/<db>?sslmode=disable"
GUARD_DB_URL="postgres://<user>:<user>@<host>:<port>/<db>?sslmode=disable"
ADMIN_NAME="admin"
ADMIN_EMAIL="admin@test.net"
ADMIN_PASSWORD="secret"
GUARD_REDIS_URL="redis://guard:guard@localhost:6379/0"
JWT_PRIVATE_KEY="ecdsa"
JWT_PUBLIC_KEY="ecdsa"
GUARD_ADMIN_NAME="admin"
GUARD_ADMIN_EMAIL="admin@test.net"
GUARD_ADMIN_PASSWORD="secret"
MINIO_ENDPOINT="localhost:9000"
MINIO_ACCESS_KEY=""
MINIO_SECRET_KEY=""
GUARD_JWT_PRIVATE="rsa"
GUARD_JWT_PUBLIC="rsa"
GUARD_JWT_KID="my-rsa-key-1"
GUARD_MINIO_ENDPOINT="localhost:9000"
GUARD_MINIO_ACCESS_KEY=""
GUARD_MINIO_SECRET_KEY=""
GOOSE_DRIVER="postgres"
GOOSE_DBSTRING=$DATABASE_URL
GOOSE_MIGRATION_DIR="./migrations"

4
.gitignore vendored
View File

@ -13,6 +13,8 @@
bin/*
tmp/
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
@ -29,4 +31,6 @@ go.work.sum
# key files
*.pem
NUL
dist/

55
Dockerfile Normal file
View File

@ -0,0 +1,55 @@
# Stage 1: Build frontend
FROM node:22 AS frontend-builder
WORKDIR /app/web
COPY web/ .
RUN npm install && npm run build
# Stage 2: Build backend
FROM golang:1.24 AS backend-builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Copy built frontend into Go embed path (adjust if needed)
# COPY --from=frontend-builder /app/web/dist ./web/dist
RUN CGO_ENABLED=0 GOOS=linux make build
# Stage 3: Final image
FROM debian:bookworm-slim
WORKDIR /app
# Install CA certificates for HTTPS
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=backend-builder /app/bin/hspguard .
COPY --from=frontend-builder /app/dist ./dist
COPY redis.conf /config/redis.conf
# Optional: copy default .env file if used
# COPY .env .env
# Set environment variables (can be overridden at runtime)
ENV ENV=production \
GUARD_PORT=3001 \
GUARD_HOST="127.0.0.1" \
GUARD_URI="http://localhost:3001" \
GUARD_DB_URL="postgres://user:user@localhost:5432/db?sslmode=disable" \
GUARD_ADMIN_NAME="admin" \
GUARD_ADMIN_EMAIL="admin@test.net" \
GUARD_ADMIN_PASSWORD="secret" \
GUARD_JWT_PRIVATE="rsa" \
GUARD_JWT_PUBLIC="rsa" \
GUARD_JWT_KID="my-rsa-key-1" \
GUARD_MINIO_ENDPOINT="localhost:9000" \
GUARD_MINIO_ACCESS_KEY="" \
GUARD_MINIO_SECRET_KEY="" \
GOOSE_DRIVER="postgres" \
GOOSE_DBSTRING=$GUARD_DB_URL \
GOOSE_MIGRATION_DIR="./migrations"
EXPOSE 3001
CMD ["./hspguard"]

View File

@ -1,11 +1,14 @@
# Project metadata
APP_NAME := hspguard
CMD_DIR := ./cmd/$(APP_NAME)
BIN_DIR := ./bin
BIN_PATH := $(BIN_DIR)/$(APP_NAME)
# Detect platform and add .exe suffix on Windows
OS := $(shell go env GOOS)
EXT := $(if $(filter windows,$(OS)),.exe,)
BIN_PATH := $(BIN_DIR)/$(APP_NAME)$(EXT)
PKG := ./...
GO_FILES := $(shell find . -type f -name '*.go' -not -path "./vendor/*")
# Go tools
GO := go
@ -16,21 +19,20 @@ GOTEST := go test
# Build flags
LD_FLAGS := -s -w
BUILD_TIME := $(shell date -u '+%Y-%m-%dT%H:%M:%SZ')
GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
GIT_COMMIT := $(shell git rev-parse --short HEAD 2>NUL || echo unknown)
.PHONY: all build clean fmt lint run
.PHONY: all build clean fmt lint run test mod
all: build
build:
@mkdir -p $(BIN_DIR)
$(GO) build -ldflags "-X main.buildTime=$(BUILD_TIME) -X main.commitHash=$(GIT_COMMIT) $(LD_FLAGS)" -o $(BIN_PATH) $(CMD_DIR)
run:
$(GO) run $(CMD_DIR)
fmt:
$(GOFMT) -s -w $(GO_FILES)
$(GOFMT) -s -w .
lint:
$(GOLINT) run
@ -39,8 +41,7 @@ test:
$(GOTEST) -v $(PKG)
clean:
@rm -rf $(BIN_DIR)
@if [ -d "$(BIN_DIR)" ]; then rm -rf $(BIN_DIR); fi
mod:
$(GO) mod tidy

View File

@ -6,8 +6,11 @@ import (
"net/http"
"os"
"gitea.local/admin/hspguard/internal/admin"
"gitea.local/admin/hspguard/internal/auth"
imiddleware "gitea.local/admin/hspguard/internal/middleware"
"gitea.local/admin/hspguard/internal/cache"
"gitea.local/admin/hspguard/internal/config"
"gitea.local/admin/hspguard/internal/oauth"
"gitea.local/admin/hspguard/internal/repository"
"gitea.local/admin/hspguard/internal/storage"
"gitea.local/admin/hspguard/internal/user"
@ -19,13 +22,17 @@ type APIServer struct {
addr string
repo *repository.Queries
storage *storage.FileStorage
cache *cache.Client
cfg *config.AppConfig
}
func NewAPIServer(addr string, db *repository.Queries, minio *storage.FileStorage) *APIServer {
func NewAPIServer(addr string, db *repository.Queries, minio *storage.FileStorage, cache *cache.Client, cfg *config.AppConfig) *APIServer {
return &APIServer{
addr: addr,
repo: db,
storage: minio,
cache: cache,
cfg: cfg,
}
}
@ -37,16 +44,24 @@ func (s *APIServer) Run() error {
// staticDir := http.Dir(filepath.Join(workDir, "static"))
// FileServer(router, "/static", staticDir)
router.Route("/api/v1", func(r chi.Router) {
r.Use(imiddleware.WithSkipper(imiddleware.AuthMiddleware, "/api/v1/login", "/api/v1/register"))
oauthHandler := oauth.NewOAuthHandler(s.repo, s.cache, s.cfg)
userHandler := user.NewUserHandler(s.repo, s.storage)
router.Route("/api/v1", func(r chi.Router) {
userHandler := user.NewUserHandler(s.repo, s.storage, s.cfg)
userHandler.RegisterRoutes(r)
authHandler := auth.NewAuthHandler(s.repo)
authHandler := auth.NewAuthHandler(s.repo, s.cache, s.cfg)
authHandler.RegisterRoutes(r)
oauthHandler.RegisterRoutes(r)
adminHandler := admin.New(s.repo, s.cfg)
adminHandler.RegisterRoutes(r)
})
router.Get("/.well-known/jwks.json", oauthHandler.WriteJWKS)
router.Get("/.well-known/openid-configuration", oauthHandler.OpenIdConfiguration)
router.Get("/*", func(w http.ResponseWriter, r *http.Request) {
path := "./dist" + r.URL.Path
if _, err := os.Stat(path); os.IsNotExist(err) {

View File

@ -7,23 +7,32 @@ import (
"os"
"gitea.local/admin/hspguard/cmd/hspguard/api"
"gitea.local/admin/hspguard/internal/cache"
"gitea.local/admin/hspguard/internal/config"
"gitea.local/admin/hspguard/internal/repository"
"gitea.local/admin/hspguard/internal/storage"
"gitea.local/admin/hspguard/internal/user"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/joho/godotenv"
)
func main() {
err := godotenv.Load()
if err != nil && os.Getenv("ENV") != "production" {
log.Fatalln("WARNING: .env file not found. Skipping...")
}
var cfg config.AppConfig
err = config.LoadEnv(&cfg)
if err != nil {
log.Fatalln("ERR: Failed to load environment variables:", err)
log.Fatal(err)
return
}
ctx := context.Background()
conn, err := pgx.Connect(ctx, os.Getenv("DATABASE_URL"))
conn, err := pgxpool.New(ctx, cfg.DatabaseURL)
if err != nil {
log.Fatalln("ERR: Failed to connect to db:", err)
return
@ -31,21 +40,13 @@ func main() {
repo := repository.New(conn)
fStorage := storage.New()
fStorage := storage.New(&cfg)
user.EnsureAdminUser(ctx, repo)
cache := cache.NewClient(&cfg)
host := os.Getenv("HOST")
if host == "" {
host = "127.0.0.1"
}
user.EnsureAdminUser(ctx, &cfg, repo)
port := os.Getenv("PORT")
if port == "" {
port = "3000"
}
server := api.NewAPIServer(fmt.Sprintf("%s:%s", host, port), repo, fStorage)
server := api.NewAPIServer(fmt.Sprintf("%s:%s", cfg.Host, cfg.Port), repo, fStorage, cache, &cfg)
if err := server.Run(); err != nil {
log.Fatalln("ERR: Failed to start server:", err)
}

View File

@ -1,4 +1,3 @@
services:
db:
image: postgres
@ -10,3 +9,17 @@ services:
ports:
- "5432:5432"
cache:
image: redis:7.2 # or newer
container_name: guard-redis
ports:
- "6379:6379"
volumes:
- redis-data:/data
- ./redis.conf:/usr/local/etc/redis/redis.conf
command: ["redis-server", "/usr/local/etc/redis/redis.conf"]
restart: unless-stopped
volumes:
redis-data:
driver: local

5
go.mod
View File

@ -11,21 +11,26 @@ require (
)
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/minio/crc64nvme v1.0.1 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/minio/minio-go/v7 v7.0.92 // indirect
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect
github.com/redis/go-redis/v9 v9.10.0 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/tinylib/msgp v1.3.0 // indirect
golang.org/x/crypto v0.38.0 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.0 // indirect
)

6
go.sum
View File

@ -1,6 +1,10 @@
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
@ -38,6 +42,8 @@ github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1Gsh
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.10.0 h1:FxwK3eV8p/CQa0Ch276C7u2d0eNC9kCmAYQ7mCXCzVs=
github.com/redis/go-redis/v9 v9.10.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=

View File

@ -0,0 +1,342 @@
package admin
import (
"encoding/json"
"log"
"net/http"
"time"
"gitea.local/admin/hspguard/internal/repository"
"gitea.local/admin/hspguard/internal/util"
"gitea.local/admin/hspguard/internal/web"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
type ApiServiceDTO struct {
ID uuid.UUID `json:"id"`
ClientID string `json:"client_id"`
Name string `json:"name"`
Description *string `json:"description"`
IconUrl *string `json:"icon_url"`
RedirectUris []string `json:"redirect_uris"`
Scopes []string `json:"scopes"`
GrantTypes []string `json:"grant_types"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
IsActive bool `json:"is_active"`
}
func NewApiServiceDTO(service repository.ApiService) ApiServiceDTO {
return ApiServiceDTO{
ID: service.ID,
ClientID: service.ClientID,
Name: service.Name,
Description: service.Description,
IconUrl: service.IconUrl,
RedirectUris: service.RedirectUris,
Scopes: service.Scopes,
GrantTypes: service.GrantTypes,
CreatedAt: service.CreatedAt,
UpdatedAt: service.UpdatedAt,
IsActive: service.IsActive,
}
}
func (h *AdminHandler) GetApiServices(w http.ResponseWriter, r *http.Request) {
services, err := h.repo.ListApiServices(r.Context())
if err != nil {
log.Println("ERR: Failed to list api services from db:", err)
web.Error(w, "failed to get api services", http.StatusInternalServerError)
return
}
apiServices := make([]ApiServiceDTO, 0)
for _, service := range services {
apiServices = append(apiServices, NewApiServiceDTO(service))
}
type Response struct {
Items []ApiServiceDTO `json:"items"`
Count int `json:"count"`
}
encoder := json.NewEncoder(w)
w.Header().Set("Content-Type", "application/json")
if err := encoder.Encode(Response{
Items: apiServices,
Count: len(apiServices),
}); err != nil {
web.Error(w, "failed to encode response", http.StatusInternalServerError)
}
}
type AddServiceRequest struct {
Name string `json:"name"`
Description string `json:"description"`
RedirectUris []string `json:"redirect_uris"`
Scopes []string `json:"scopes"`
GrantTypes []string `json:"grant_types"`
IsActive bool `json:"is_active"`
}
type ApiServiceCredentials struct {
ClientId string `json:"client_id"`
ClientSecret string `json:"client_secret"`
}
func (h *AdminHandler) AddApiService(w http.ResponseWriter, r *http.Request) {
var req AddServiceRequest
decoder := json.NewDecoder(r.Body)
if err := decoder.Decode(&req); err != nil {
web.Error(w, "failed to parse request body", http.StatusBadRequest)
return
}
if req.Name == "" {
web.Error(w, "name is required for an api service", http.StatusBadRequest)
return
}
clientId, err := util.GenerateClientID()
if err != nil {
web.Error(w, "failed to generate client id", http.StatusInternalServerError)
return
}
clientSecret, err := util.GenerateClientSecret()
if err != nil {
web.Error(w, "failed to generate client secret", http.StatusInternalServerError)
return
}
hashSecret, err := util.HashPassword(clientSecret)
if err != nil {
web.Error(w, "failed to create client secret", http.StatusInternalServerError)
return
}
params := repository.CreateApiServiceParams{
ClientID: clientId,
ClientSecret: hashSecret,
Name: req.Name,
RedirectUris: req.RedirectUris,
Scopes: req.Scopes,
GrantTypes: req.GrantTypes,
IsActive: req.IsActive,
}
if req.Description != "" {
params.Description = &req.Description
}
service, err := h.repo.CreateApiService(r.Context(), params)
if err != nil {
web.Error(w, "failed to create new api service", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
service.ClientSecret = clientSecret
type Response struct {
Service ApiServiceDTO `json:"service"`
Credentials ApiServiceCredentials `json:"credentials"`
}
encoder := json.NewEncoder(w)
w.Header().Set("Content-Type", "application/json")
if err := encoder.Encode(Response{
Service: NewApiServiceDTO(service),
Credentials: ApiServiceCredentials{
ClientId: service.ClientID,
ClientSecret: service.ClientSecret,
},
}); err != nil {
web.Error(w, "failed to encode response", http.StatusInternalServerError)
}
}
func (h *AdminHandler) GetApiService(w http.ResponseWriter, r *http.Request) {
serviceId := chi.URLParam(r, "id")
parsed, err := uuid.Parse(serviceId)
if err != nil {
web.Error(w, "service id provided is not a valid uuid", http.StatusBadRequest)
return
}
service, err := h.repo.GetApiServiceId(r.Context(), parsed)
if err != nil {
web.Error(w, "service with provided id not found", http.StatusNotFound)
return
}
encoder := json.NewEncoder(w)
w.Header().Set("Content-Type", "application/json")
if err := encoder.Encode(NewApiServiceDTO(service)); err != nil {
web.Error(w, "failed to encode response", http.StatusInternalServerError)
}
}
func (h *AdminHandler) GetApiServiceCID(w http.ResponseWriter, r *http.Request) {
clientId := chi.URLParam(r, "client_id")
service, err := h.repo.GetApiServiceCID(r.Context(), clientId)
if err != nil {
web.Error(w, "service with provided client id not found", http.StatusNotFound)
return
}
encoder := json.NewEncoder(w)
w.Header().Set("Content-Type", "application/json")
if err := encoder.Encode(NewApiServiceDTO(service)); err != nil {
web.Error(w, "failed to encode response", http.StatusInternalServerError)
}
}
func (h *AdminHandler) RegenerateApiServiceSecret(w http.ResponseWriter, r *http.Request) {
serviceId := chi.URLParam(r, "id")
parsed, err := uuid.Parse(serviceId)
if err != nil {
web.Error(w, "provided service id is not valid", http.StatusBadRequest)
return
}
service, err := h.repo.GetApiServiceId(r.Context(), parsed)
if err != nil {
web.Error(w, "service with provided id not found", http.StatusNotFound)
return
}
clientSecret, err := util.GenerateClientSecret()
if err != nil {
web.Error(w, "failed to generate client secret", http.StatusInternalServerError)
return
}
if err := h.repo.UpdateClientSecret(r.Context(), repository.UpdateClientSecretParams{
ClientID: service.ClientID,
ClientSecret: clientSecret,
}); err != nil {
web.Error(w, "failed to update client secret for service", http.StatusInternalServerError)
return
}
encoder := json.NewEncoder(w)
w.Header().Set("Content-Type", "application/json")
if err := encoder.Encode(ApiServiceCredentials{
ClientId: service.ClientID,
ClientSecret: clientSecret,
}); err != nil {
web.Error(w, "failed to send credentials", http.StatusInternalServerError)
}
}
type UpdateApiServiceRequest struct {
Name string `json:"name"`
Description string `json:"description"`
RedirectUris []string `json:"redirect_uris"`
Scopes []string `json:"scopes"`
GrantTypes []string `json:"grant_types"`
}
func (h *AdminHandler) UpdateApiService(w http.ResponseWriter, r *http.Request) {
serviceId := chi.URLParam(r, "id")
parsed, err := uuid.Parse(serviceId)
if err != nil {
web.Error(w, "provided service id is not valid", http.StatusBadRequest)
return
}
var req UpdateApiServiceRequest
decoder := json.NewDecoder(r.Body)
if err := decoder.Decode(&req); err != nil {
web.Error(w, "missing required fields to update service", http.StatusBadRequest)
return
}
if req.Name == "" {
web.Error(w, "service name is required", http.StatusBadRequest)
return
}
if len(req.Scopes) == 0 {
web.Error(w, "at least 1 scope is required", http.StatusBadRequest)
return
}
service, err := h.repo.GetApiServiceId(r.Context(), parsed)
if err != nil {
web.Error(w, "service with provided id not found", http.StatusNotFound)
return
}
updated, err := h.repo.UpdateApiService(r.Context(), repository.UpdateApiServiceParams{
ClientID: service.ClientID,
Name: req.Name,
Description: &req.Description,
RedirectUris: req.RedirectUris,
Scopes: req.Scopes,
GrantTypes: req.GrantTypes,
})
if err != nil {
web.Error(w, "failed to update api service", http.StatusInternalServerError)
return
}
encoder := json.NewEncoder(w)
w.Header().Set("Content-Type", "application/json")
if err := encoder.Encode(NewApiServiceDTO(updated)); err != nil {
web.Error(w, "failed to send updated api service", http.StatusInternalServerError)
}
}
func (h *AdminHandler) ToggleApiService(w http.ResponseWriter, r *http.Request) {
var err error
serviceId := chi.URLParam(r, "id")
parsed, err := uuid.Parse(serviceId)
if err != nil {
web.Error(w, "provided service id is not valid", http.StatusBadRequest)
return
}
service, err := h.repo.GetApiServiceId(r.Context(), parsed)
if err != nil {
web.Error(w, "service with provided id not found", http.StatusNotFound)
return
}
if service.IsActive {
log.Println("INFO: Service is active. Deactivating...")
err = h.repo.DeactivateApiService(r.Context(), service.ClientID)
} else {
log.Println("INFO: Service is inactive. Activating...")
err = h.repo.ActivateApiService(r.Context(), service.ClientID)
}
if err != nil {
log.Printf("ERR: Failed to toggle api service (cid: %s): %v\n", service.ClientID, err)
web.Error(w, "failed to toggle api service", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}

41
internal/admin/routes.go Normal file
View File

@ -0,0 +1,41 @@
package admin
import (
"gitea.local/admin/hspguard/internal/config"
imiddleware "gitea.local/admin/hspguard/internal/middleware"
"gitea.local/admin/hspguard/internal/repository"
"github.com/go-chi/chi/v5"
)
type AdminHandler struct {
repo *repository.Queries
cfg *config.AppConfig
}
func New(repo *repository.Queries, cfg *config.AppConfig) *AdminHandler {
return &AdminHandler{
repo,
cfg,
}
}
func (h *AdminHandler) RegisterRoutes(router chi.Router) {
router.Route("/admin", func(r chi.Router) {
authMiddleware := imiddleware.NewAuthMiddleware(h.cfg)
adminMiddleware := imiddleware.NewAdminMiddleware(h.repo)
r.Use(authMiddleware.Runner, adminMiddleware.Runner)
r.Get("/api-services", h.GetApiServices)
r.Get("/api-services/{id}", h.GetApiService)
r.Post("/api-services", h.AddApiService)
r.Patch("/api-services/{id}", h.RegenerateApiServiceSecret)
r.Put("/api-services/{id}", h.UpdateApiService)
r.Patch("/api-services/toggle/{id}", h.ToggleApiService)
r.Get("/users", h.GetUsers)
r.Post("/users", h.CreateUser)
r.Get("/users/{id}", h.GetUser)
})
router.Get("/api-services/client/{client_id}", h.GetApiServiceCID)
}

165
internal/admin/users.go Normal file
View File

@ -0,0 +1,165 @@
package admin
import (
"encoding/json"
"log"
"net/http"
"gitea.local/admin/hspguard/internal/repository"
"gitea.local/admin/hspguard/internal/types"
"gitea.local/admin/hspguard/internal/util"
"gitea.local/admin/hspguard/internal/web"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
func (h *AdminHandler) GetUsers(w http.ResponseWriter, r *http.Request) {
userId, ok := util.GetRequestUserId(r.Context())
if !ok {
web.Error(w, "failed to get user id from auth session", http.StatusInternalServerError)
return
}
user, err := h.repo.FindUserId(r.Context(), uuid.MustParse(userId))
if err != nil {
web.Error(w, "failed to get access information", http.StatusUnauthorized)
return
}
users, err := h.repo.FindAdminUsers(r.Context(), &user.ID)
if err != nil {
log.Println("ERR: Failed to query users from db:", err)
web.Error(w, "failed to get all users", http.StatusInternalServerError)
return
}
type Response struct {
Items []types.UserDTO `json:"items"`
Count int `json:"count"`
}
var items []types.UserDTO
for _, user := range users {
items = append(items, types.NewUserDTO(&user))
}
encoder := json.NewEncoder(w)
w.Header().Set("Content-Type", "application/json")
if err := encoder.Encode(&Response{
Items: items,
Count: len(items),
}); err != nil {
web.Error(w, "failed to send response", http.StatusInternalServerError)
}
}
func (h *AdminHandler) GetUser(w http.ResponseWriter, r *http.Request) {
userId := chi.URLParam(r, "id")
parsed, err := uuid.Parse(userId)
if err != nil {
web.Error(w, "user id provided is not a valid uuid", http.StatusBadRequest)
return
}
user, err := h.repo.FindUserId(r.Context(), parsed)
if err != nil {
web.Error(w, "user with provided id not found", http.StatusNotFound)
return
}
encoder := json.NewEncoder(w)
w.Header().Set("Content-Type", "application/json")
if err := encoder.Encode(types.NewUserDTO(&user)); err != nil {
web.Error(w, "failed to encode user dto", http.StatusInternalServerError)
}
}
type CreateUserRequest struct {
Email string `json:"email"`
FullName string `json:"full_name"`
Password string `json:"password"`
IsAdmin bool `json:"is_admin"`
}
func (h *AdminHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
userId, ok := util.GetRequestUserId(r.Context())
if !ok {
web.Error(w, "failed to get user id from auth session", http.StatusInternalServerError)
return
}
user, err := h.repo.FindUserId(r.Context(), uuid.MustParse(userId))
if err != nil {
web.Error(w, "failed to get access information", http.StatusUnauthorized)
return
}
var req CreateUserRequest
decoder := json.NewDecoder(r.Body)
if err := decoder.Decode(&req); err != nil {
web.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if req.Email == "" {
web.Error(w, "email is required", http.StatusBadRequest)
return
}
if req.FullName == "" {
web.Error(w, "full name is required", http.StatusBadRequest)
return
}
if req.Password == "" {
web.Error(w, "password is required", http.StatusBadRequest)
return
}
_, err = h.repo.FindUserEmail(r.Context(), req.Email)
if err == nil {
web.Error(w, "user with provided email already exists", http.StatusBadRequest)
return
}
hash, err := util.HashPassword(req.Password)
if err != nil {
log.Println("ERR: Failed to hash password for new user:", err)
web.Error(w, "failed to create user account", http.StatusInternalServerError)
return
}
params := repository.InsertUserParams{
Email: req.Email,
FullName: req.FullName,
PasswordHash: hash,
IsAdmin: false,
CreatedBy: &user.ID,
}
log.Println("INFO: params for user creation:", params)
id, err := h.repo.InsertUser(r.Context(), params)
if err != nil {
log.Println("ERR: Failed to insert user into database:", err)
web.Error(w, "failed to create user", http.StatusInternalServerError)
return
}
type Response struct {
ID string `json:"id"`
}
encoder := json.NewEncoder(w)
if err := encoder.Encode(Response{
ID: id.String(),
}); err != nil {
web.Error(w, "failed to encode response", http.StatusInternalServerError)
}
}

View File

@ -1,91 +0,0 @@
package auth
import (
"crypto/ecdsa"
"crypto/x509"
"encoding/base64"
"fmt"
"os"
"gitea.local/admin/hspguard/internal/types"
"github.com/golang-jwt/jwt/v5"
)
func parseBase64PrivateKey(envVar string) (*ecdsa.PrivateKey, error) {
b64 := os.Getenv(envVar)
if b64 == "" {
return nil, fmt.Errorf("env var %s is empty", envVar)
}
decoded, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
return nil, fmt.Errorf("failed to decode base64 key: %v", err)
}
return x509.ParseECPrivateKey(decoded)
}
func parseBase64PublicKey(envVar string) (*ecdsa.PublicKey, error) {
b64 := os.Getenv(envVar)
if b64 == "" {
return nil, fmt.Errorf("env var %s is empty", envVar)
}
decoded, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
return nil, fmt.Errorf("failed to decode base64 key: %v", err)
}
pubInterface, err := x509.ParsePKIXPublicKey(decoded)
if err != nil {
return nil, fmt.Errorf("failed to parse public key: %v", err)
}
pubKey, ok := pubInterface.(*ecdsa.PublicKey)
if !ok {
return nil, fmt.Errorf("not an ECDSA public key")
}
return pubKey, nil
}
func SignJwtToken(claims jwt.Claims) (string, error) {
privateKey, err := parseBase64PrivateKey("JWT_PRIVATE_KEY")
if err != nil {
return "", err
}
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
s, err := token.SignedString(privateKey)
if err != nil {
return "", err
}
return s, nil
}
func VerifyToken(token string) (*jwt.Token, *types.UserClaims, error) {
publicKey, err := parseBase64PublicKey("JWT_PUBLIC_KEY")
if err != nil {
return nil, nil, err
}
claims := &types.UserClaims{}
parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodECDSA); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return publicKey, nil
})
if err != nil {
return nil, nil, fmt.Errorf("invalid token: %w", err)
}
if !parsed.Valid {
return nil, nil, fmt.Errorf("token is not valid")
}
return parsed, claims, nil
}

81
internal/auth/login.go Normal file
View File

@ -0,0 +1,81 @@
package auth
import (
"encoding/json"
"log"
"net/http"
"gitea.local/admin/hspguard/internal/util"
"gitea.local/admin/hspguard/internal/web"
)
type LoginParams struct {
Email string `json:"email"`
Password string `json:"password"`
}
func (h *AuthHandler) login(w http.ResponseWriter, r *http.Request) {
var params LoginParams
decoder := json.NewDecoder(r.Body)
if err := decoder.Decode(&params); err != nil {
web.Error(w, "failed to parse request body", http.StatusBadRequest)
return
}
if params.Email == "" || params.Password == "" {
web.Error(w, "missing required fields", http.StatusBadRequest)
return
}
log.Printf("DEBUG: looking for user with following params: %#v\n", params)
user, err := h.repo.FindUserEmail(r.Context(), params.Email)
if err != nil {
web.Error(w, "user with provided email does not exists", http.StatusBadRequest)
return
}
if !util.VerifyPassword(params.Password, user.PasswordHash) {
web.Error(w, "username or/and password are incorrect", http.StatusBadRequest)
return
}
access, refresh, err := h.signTokens(&user)
if err != nil {
web.Error(w, "failed to generate tokens", http.StatusInternalServerError)
return
}
if err := h.repo.UpdateLastLogin(r.Context(), user.ID); err != nil {
web.Error(w, "failed to update user's last login", http.StatusInternalServerError)
return
}
encoder := json.NewEncoder(w)
type Response struct {
AccessToken string `json:"access"`
RefreshToken string `json:"refresh"`
// fields required for UI in account selector, e.g. email, full name and avatar
FullName string `json:"full_name"`
Email string `json:"email"`
Id string `json:"id"`
ProfilePicture *string `json:"profile_picture"`
// Avatar
}
w.Header().Set("Content-Type", "application/json")
if err := encoder.Encode(Response{
AccessToken: access,
RefreshToken: refresh,
FullName: user.FullName,
Email: user.Email,
Id: user.ID.String(),
ProfilePicture: user.ProfilePicture,
// Avatar
}); err != nil {
web.Error(w, "failed to encode response", http.StatusInternalServerError)
}
}

31
internal/auth/profile.go Normal file
View File

@ -0,0 +1,31 @@
package auth
import (
"encoding/json"
"net/http"
"gitea.local/admin/hspguard/internal/types"
"gitea.local/admin/hspguard/internal/util"
"gitea.local/admin/hspguard/internal/web"
"github.com/google/uuid"
)
func (h *AuthHandler) getProfile(w http.ResponseWriter, r *http.Request) {
userId, ok := util.GetRequestUserId(r.Context())
if !ok {
web.Error(w, "failed to get user id from auth session", http.StatusInternalServerError)
return
}
user, err := h.repo.FindUserId(r.Context(), uuid.MustParse(userId))
if err != nil {
web.Error(w, "user with provided id does not exist", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(types.NewUserDTO(&user)); err != nil {
web.Error(w, "failed to encode user profile", http.StatusInternalServerError)
}
}

82
internal/auth/refresh.go Normal file
View File

@ -0,0 +1,82 @@
package auth
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"gitea.local/admin/hspguard/internal/types"
"gitea.local/admin/hspguard/internal/util"
"gitea.local/admin/hspguard/internal/web"
"github.com/google/uuid"
)
func (h *AuthHandler) refreshToken(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
web.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
parts := strings.Split(authHeader, "Bearer ")
if len(parts) != 2 {
web.Error(w, "invalid auth header format", http.StatusUnauthorized)
return
}
tokenStr := parts[1]
var userClaims types.UserClaims
token, err := util.VerifyToken(tokenStr, h.cfg.Jwt.PublicKey, &userClaims)
if err != nil || !token.Valid {
http.Error(w, fmt.Sprintf("invalid token: %v", err), http.StatusUnauthorized)
return
}
expire, err := userClaims.GetExpirationTime()
if err != nil {
web.Error(w, "failed to retrieve enough info from the token", http.StatusInternalServerError)
return
}
if time.Now().After(expire.Time) {
web.Error(w, "token is expired", http.StatusUnauthorized)
return
}
userId, err := uuid.Parse(userClaims.Subject)
if err != nil {
web.Error(w, "failed to parsej user id from token", http.StatusInternalServerError)
return
}
user, err := h.repo.FindUserId(r.Context(), userId)
if err != nil {
web.Error(w, "user with provided email does not exists", http.StatusBadRequest)
return
}
access, refresh, err := h.signTokens(&user)
if err != nil {
web.Error(w, "failed to generate tokens", http.StatusInternalServerError)
return
}
type Response struct {
AccessToken string `json:"access"`
RefreshToken string `json:"refresh"`
}
encoder := json.NewEncoder(w)
w.Header().Set("Content-Type", "application/json")
if err := encoder.Encode(Response{
AccessToken: access,
RefreshToken: refresh,
}); err != nil {
web.Error(w, "failed to encode response", http.StatusInternalServerError)
}
}

View File

@ -1,146 +1,81 @@
package auth
import (
"encoding/json"
"fmt"
"net/http"
"time"
"gitea.local/admin/hspguard/internal/cache"
"gitea.local/admin/hspguard/internal/config"
imiddleware "gitea.local/admin/hspguard/internal/middleware"
"gitea.local/admin/hspguard/internal/repository"
"gitea.local/admin/hspguard/internal/types"
"gitea.local/admin/hspguard/internal/util"
"gitea.local/admin/hspguard/internal/web"
"github.com/go-chi/chi/v5"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
type AuthHandler struct {
repo *repository.Queries
repo *repository.Queries
cache *cache.Client
cfg *config.AppConfig
}
func NewAuthHandler(repo *repository.Queries) *AuthHandler {
return &AuthHandler{
repo: repo,
}
}
func (h *AuthHandler) RegisterRoutes(api chi.Router) {
api.Get("/profile", h.getProfile)
api.Post("/login", h.login)
}
func (h *AuthHandler) getProfile(w http.ResponseWriter, r *http.Request) {
userId, ok := util.GetRequestUserId(r.Context())
if !ok {
web.Error(w, "failed to get user id from auth session", http.StatusInternalServerError)
return
}
user, err := h.repo.FindUserId(r.Context(), uuid.MustParse(userId))
if err != nil {
web.Error(w, "user with provided id does not exist", http.StatusUnauthorized)
return
}
if err := json.NewEncoder(w).Encode(map[string]any{
"full_name": user.FullName,
"email": user.Email,
"phoneNumber": user.PhoneNumber,
"isAdmin": user.IsAdmin,
"last_login": user.LastLogin,
"profile_picture": user.ProfilePicture.String,
"updated_at": user.UpdatedAt,
"created_at": user.CreatedAt,
}); err != nil {
web.Error(w, "failed to encode user profile", http.StatusInternalServerError)
}
}
type LoginParams struct {
Email string `json:"email"`
Password string `json:"password"`
}
func (h *AuthHandler) login(w http.ResponseWriter, r *http.Request) {
var params LoginParams
decoder := json.NewDecoder(r.Body)
if err := decoder.Decode(&params); err != nil {
web.Error(w, "failed to parse request body", http.StatusBadRequest)
return
}
if params.Email == "" || params.Password == "" {
web.Error(w, "missing required fields", http.StatusBadRequest)
return
}
user, err := h.repo.FindUserEmail(r.Context(), params.Email)
if err != nil {
web.Error(w, "user with provided email does not exists", http.StatusBadRequest)
return
}
if !util.VerifyPassword(params.Password, user.PasswordHash) {
web.Error(w, "username or/and password are incorrect", http.StatusBadRequest)
return
}
func (h *AuthHandler) signTokens(user *repository.User) (string, string, error) {
accessClaims := types.UserClaims{
UserEmail: user.Email,
IsAdmin: user.IsAdmin,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "hspguard",
Issuer: h.cfg.Uri,
Subject: user.ID.String(),
IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
},
}
accessToken, err := SignJwtToken(accessClaims)
accessToken, err := util.SignJwtToken(accessClaims, h.cfg.Jwt.PrivateKey)
if err != nil {
web.Error(w, fmt.Sprintf("failed to generate access token: %v", err), http.StatusBadRequest)
return
return "", "", err
}
refreshClaims := types.UserClaims{
UserEmail: user.Email,
IsAdmin: user.IsAdmin,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "hspguard",
Issuer: h.cfg.Uri,
Subject: user.ID.String(),
IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * 24 * time.Hour)),
},
}
refreshToken, err := SignJwtToken(refreshClaims)
refreshToken, err := util.SignJwtToken(refreshClaims, h.cfg.Jwt.PrivateKey)
if err != nil {
web.Error(w, fmt.Sprintf("failed to generate refresh token: %v", err), http.StatusBadRequest)
return
return "", "", err
}
encoder := json.NewEncoder(w)
return accessToken, refreshToken, nil
}
type Response struct {
AccessToken string `json:"access"`
RefreshToken string `json:"refresh"`
// fields required for UI in account selector, e.g. email, full name and avatar
FullName string `json:"full_name"`
Email string `json:"email"`
Id string `json:"id"`
ProfilePicture string `json:"profile_picture"`
// Avatar
}
if err := encoder.Encode(Response{
AccessToken: accessToken,
RefreshToken: refreshToken,
FullName: user.FullName,
Email: user.Email,
Id: user.ID.String(),
ProfilePicture: user.ProfilePicture.String,
// Avatar
}); err != nil {
web.Error(w, "failed to encode response", http.StatusInternalServerError)
func NewAuthHandler(repo *repository.Queries, cache *cache.Client, cfg *config.AppConfig) *AuthHandler {
return &AuthHandler{
repo,
cache,
cfg,
}
}
func (h *AuthHandler) RegisterRoutes(api chi.Router) {
api.Route("/auth", func(r chi.Router) {
r.Group(func(protected chi.Router) {
authMiddleware := imiddleware.NewAuthMiddleware(h.cfg)
protected.Use(authMiddleware.Runner)
protected.Get("/profile", h.getProfile)
protected.Post("/email", h.requestEmailOtp)
protected.Post("/email/otp", h.confirmOtp)
protected.Post("/verify", h.finishVerification)
})
r.Post("/login", h.login)
r.Post("/refresh", h.refreshToken)
})
}

126
internal/auth/verify.go Normal file
View File

@ -0,0 +1,126 @@
package auth
import (
"encoding/json"
"fmt"
"log"
"math/rand"
"net/http"
"time"
"gitea.local/admin/hspguard/internal/util"
"gitea.local/admin/hspguard/internal/web"
"github.com/google/uuid"
)
func (h *AuthHandler) requestEmailOtp(w http.ResponseWriter, r *http.Request) {
userId, ok := util.GetRequestUserId(r.Context())
if !ok {
web.Error(w, "failed to get user id from auth session", http.StatusInternalServerError)
return
}
user, err := h.repo.FindUserId(r.Context(), uuid.MustParse(userId))
if err != nil {
web.Error(w, "user with provided id does not exist", http.StatusUnauthorized)
return
}
if user.EmailVerified {
web.Error(w, "email is already verified", http.StatusBadRequest)
return
}
number := rand.Intn(1000000) // 0 to 999999
padded := fmt.Sprintf("%06d", number) // Always 6 characters
if _, err := h.cache.Set(r.Context(), fmt.Sprintf("otp-%s", user.ID.String()), padded, 5*time.Minute).Result(); err != nil {
log.Println("ERR: Failed to save OTP in cache:", err)
web.Error(w, "failed to generate otp", http.StatusInternalServerError)
return
}
log.Printf("INFO: Saved OTP %s\n", padded)
w.WriteHeader(http.StatusCreated)
}
type ConfirmOtpRequest struct {
OTP string `json:"otp"`
}
func (h *AuthHandler) confirmOtp(w http.ResponseWriter, r *http.Request) {
userId, ok := util.GetRequestUserId(r.Context())
if !ok {
web.Error(w, "failed to get user id from auth session", http.StatusInternalServerError)
return
}
user, err := h.repo.FindUserId(r.Context(), uuid.MustParse(userId))
if err != nil {
web.Error(w, "user with provided id does not exist", http.StatusUnauthorized)
return
}
if user.EmailVerified {
web.Error(w, "email is already verified", http.StatusBadRequest)
return
}
var req ConfirmOtpRequest
decoder := json.NewDecoder(r.Body)
if err := decoder.Decode(&req); err != nil {
web.Error(w, "invalid request", http.StatusBadRequest)
return
}
val, err := h.cache.Get(r.Context(), fmt.Sprintf("otp-%s", user.ID.String())).Result()
if err != nil {
web.Error(w, "otp verification session not found", http.StatusNotFound)
return
}
log.Printf("INFO: Comparing OTP %s == %s\n", req.OTP, val)
if req.OTP == val {
err := h.repo.UserVerifyEmail(r.Context(), user.ID)
if err != nil {
log.Println("ERR: Failed to update email_verified:", err)
web.Error(w, "failed to verify email", http.StatusInternalServerError)
return
}
} else {
web.Error(w, "otp verification failed", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
}
func (h *AuthHandler) finishVerification(w http.ResponseWriter, r *http.Request) {
userId, ok := util.GetRequestUserId(r.Context())
if !ok {
web.Error(w, "failed to get user id from auth session", http.StatusInternalServerError)
return
}
user, err := h.repo.FindUserId(r.Context(), uuid.MustParse(userId))
if err != nil {
web.Error(w, "user with provided id does not exist", http.StatusUnauthorized)
return
}
if !user.EmailVerified || !user.AvatarVerified {
web.Error(w, "finish other verification steps before final verify", http.StatusBadRequest)
return
}
if err := h.repo.UserVerifyComplete(r.Context(), user.ID); err != nil {
log.Println("ERR: Failed to update verified on user:", err)
web.Error(w, "failed to verify user", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}

84
internal/cache/mod.go vendored Normal file
View File

@ -0,0 +1,84 @@
package cache
import (
"encoding/json"
"fmt"
"log"
"time"
"gitea.local/admin/hspguard/internal/config"
"github.com/redis/go-redis/v9"
"golang.org/x/net/context"
)
type Client struct {
rClient *redis.Client
}
func NewClient(cfg *config.AppConfig) *Client {
opts, err := redis.ParseURL(cfg.RedisURL)
if err != nil {
log.Fatalln("ERR: Failed to get redis options:", err)
return nil
}
client := redis.NewClient(opts)
return &Client{
rClient: client,
}
}
type OAuthCode struct {
ClientID string `json:"client_id"`
UserID string `json:"user_id"`
Nonce string `json:"nonce"`
}
type SaveAuthCodeParams struct {
AuthCode string
UserID string
ClientID string
Nonce string
}
func (c *Client) Set(ctx context.Context, key string, value any, expiration time.Duration) *redis.StatusCmd {
return c.rClient.Set(ctx, key, value, expiration)
}
func (c *Client) SaveAuthCode(ctx context.Context, params *SaveAuthCodeParams) error {
code := OAuthCode{
ClientID: params.ClientID,
UserID: params.UserID,
Nonce: params.Nonce,
}
row, err := json.Marshal(&code)
if err != nil {
return err
}
return c.Set(ctx, fmt.Sprintf("oauth.%s", params.AuthCode), string(row), 5*time.Minute).Err()
}
func (c *Client) GetAuthCode(ctx context.Context, authCode string) (*OAuthCode, error) {
row, err := c.Get(ctx, fmt.Sprintf("oauth.%s", authCode)).Result()
if err != nil {
return nil, err
}
if len(row) == 0 {
return nil, fmt.Errorf("no auth params found under %s", authCode)
}
var parsed OAuthCode
if err := json.Unmarshal([]byte(row), &parsed); err != nil {
return nil, err
}
return &parsed, nil
}
func (c *Client) Get(ctx context.Context, key string) *redis.StringCmd {
return c.rClient.Get(ctx, key)
}

7
internal/config/admin.go Normal file
View File

@ -0,0 +1,7 @@
package config
type AdminConfig struct {
Name string `env:"GUARD_ADMIN_NAME" default:"Admin"`
Email string `env:"GUARD_ADMIN_EMAIL" required:"true"`
Password string `env:"GUARD_ADMIN_PASSWORD" required:"true"`
}

7
internal/config/jwt.go Normal file
View File

@ -0,0 +1,7 @@
package config
type JwtConfig struct {
PrivateKey string `env:"GUARD_JWT_PRIVATE" required:"true"`
PublicKey string `env:"GUARD_JWT_PUBLIC" required:"true"`
KID string `env:"GUARD_JWT_KID" default:"guard-rsa"`
}

7
internal/config/minio.go Normal file
View File

@ -0,0 +1,7 @@
package config
type MinioConfig struct {
Endpoint string `env:"GUARD_MINIO_ENDPOINT" default:"localhost:9000"`
AccessKey string `env:"GUARD_MINIO_ACCESS_KEY" required:"true"`
SecretKey string `env:"GUARD_MINIO_SECRET_KEY" required:"true"`
}

100
internal/config/mod.go Normal file
View File

@ -0,0 +1,100 @@
package config
import (
"errors"
"fmt"
"os"
"reflect"
"strconv"
"strings"
)
type AppConfig struct {
Port string `env:"GUARD_PORT" default:"3001"`
Host string `env:"GUARD_HOST" default:"127.0.0.1"`
Uri string `env:"GUARD_URI" default:"http://127.0.0.1:3001"`
DatabaseURL string `env:"GUARD_DB_URL" required:"true"`
RedisURL string `env:"GUARD_REDIS_URL" default:"redis://localhost:6379/0"`
Admin AdminConfig
Jwt JwtConfig
Minio MinioConfig
}
func LoadEnv(target any) error {
v := reflect.ValueOf(target)
if v.Kind() != reflect.Pointer || v.Elem().Kind() != reflect.Struct {
return &InvalidTargetError{}
}
return loadStruct(v.Elem(), "")
}
type InvalidTargetError struct{}
func (e *InvalidTargetError) Error() string {
return "target must be a pointer to a struct"
}
var ErrMissingRequiredEnv = errors.New("missing required environment variable")
func loadStruct(v reflect.Value, prefix string) error {
t := v.Type()
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
valueField := v.Field(i)
if !valueField.CanSet() {
continue
}
envKey := field.Tag.Get("env")
if envKey == "" {
envKey = strings.ToUpper(prefix + "_" + field.Name)
envKey = strings.TrimPrefix(envKey, "_")
}
required := field.Tag.Get("required") == "true"
defaultVal := field.Tag.Get("default")
if field.Type.Kind() == reflect.Struct {
err := loadStruct(valueField, envKey)
if err != nil {
return err
}
continue
}
envVal := os.Getenv(envKey)
if envVal == "" {
if defaultVal != "" {
envVal = defaultVal
} else if required {
return fmt.Errorf("%w: %s", ErrMissingRequiredEnv, envKey)
} else {
continue
}
}
switch field.Type.Kind() {
case reflect.String:
valueField.SetString(envVal)
case reflect.Int, reflect.Int64:
i, err := strconv.ParseInt(envVal, 10, 64)
if err != nil {
return fmt.Errorf("invalid int for %s: %w", envKey, err)
}
valueField.SetInt(i)
case reflect.Bool:
b, err := strconv.ParseBool(envVal)
if err != nil {
return fmt.Errorf("invalid bool for %s: %w", envKey, err)
}
valueField.SetBool(b)
default:
return fmt.Errorf("unsupported type for field %s", field.Name)
}
}
return nil
}

View File

@ -0,0 +1,47 @@
package middleware
import (
"log"
"net/http"
"gitea.local/admin/hspguard/internal/repository"
"gitea.local/admin/hspguard/internal/util"
"gitea.local/admin/hspguard/internal/web"
"github.com/google/uuid"
)
type AdminMiddleware struct {
repo *repository.Queries
}
func NewAdminMiddleware(repo *repository.Queries) *AdminMiddleware {
return &AdminMiddleware{
repo,
}
}
func (m *AdminMiddleware) Runner(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userId, ok := util.GetRequestUserId(r.Context())
if !ok {
log.Println("ERR: Could not get user id from request")
web.Error(w, "not authenticated", http.StatusUnauthorized)
return
}
user, err := m.repo.FindUserId(r.Context(), uuid.MustParse(userId))
if err != nil {
log.Println("ERR: User with provided id does not exist:", userId)
web.Error(w, "not authenticated", http.StatusUnauthorized)
return
}
if !user.IsAdmin {
log.Println("INFO: User is not admin")
web.Error(w, "no priviligies to access this resource", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}

View File

@ -6,12 +6,23 @@ import (
"net/http"
"strings"
"gitea.local/admin/hspguard/internal/auth"
"gitea.local/admin/hspguard/internal/config"
"gitea.local/admin/hspguard/internal/types"
"gitea.local/admin/hspguard/internal/util"
"gitea.local/admin/hspguard/internal/web"
)
func AuthMiddleware(next http.Handler) http.Handler {
type AuthMiddleware struct {
cfg *config.AppConfig
}
func NewAuthMiddleware(cfg *config.AppConfig) *AuthMiddleware {
return &AuthMiddleware{
cfg,
}
}
func (m *AuthMiddleware) Runner(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
@ -26,9 +37,11 @@ func AuthMiddleware(next http.Handler) http.Handler {
}
tokenStr := parts[1]
token, userClaims, err := auth.VerifyToken(tokenStr)
var userClaims types.UserClaims
token, err := util.VerifyToken(tokenStr, m.cfg.Jwt.PublicKey, &userClaims)
if err != nil || !token.Valid {
http.Error(w, fmt.Sprintf("invalid token: %v", err), http.StatusUnauthorized)
web.Error(w, fmt.Sprintf("invalid token: %v", err), http.StatusUnauthorized)
return
}

View File

@ -0,0 +1,74 @@
package oauth
import (
"fmt"
"net/http"
"slices"
"strings"
"gitea.local/admin/hspguard/internal/web"
)
// client_id=gitea-client&redirect_uri=https://git.adalspace.com/user/oauth2/Home%20Guard/callback&response_type=code&scope=openid&state=4c3b4a25-9cf9-4b18-afc0-270e1078eb40
func (h *OAuthHandler) AuthorizeClient(w http.ResponseWriter, r *http.Request) {
redirectUri := r.URL.Query().Get("redirect_uri")
if redirectUri == "" {
web.Error(w, "redirect_uri is missing in request", http.StatusBadRequest)
return
}
state := r.URL.Query().Get("state")
clientId := r.URL.Query().Get("client_id")
if clientId == "" {
uri := fmt.Sprintf("%s?error=invalid_request&error_description=ClientID+is+missing", redirectUri)
if state != "" {
uri += "&state=" + state
}
http.Redirect(w, r, uri, http.StatusFound)
return
}
client, err := h.repo.GetApiServiceCID(r.Context(), clientId)
if err != nil {
uri := fmt.Sprintf("%s?error=access_denied&error_description=Service+not+authorized", redirectUri)
if state != "" {
uri += "&state=" + state
}
http.Redirect(w, r, uri, http.StatusFound)
return
}
if !client.IsActive {
uri := fmt.Sprintf("%s?error=temporarily_unavailable&error_description=Service+not+active", redirectUri)
if state != "" {
uri += "&state=" + state
}
http.Redirect(w, r, uri, http.StatusFound)
return
}
scopes := strings.SplitSeq(strings.TrimSpace(r.URL.Query().Get("scope")), " ")
for scope := range scopes {
if !slices.Contains(client.Scopes, scope) {
uri := fmt.Sprintf("%s?error=invalid_scope&error_description=Scope+%s+is+not+allowed", redirectUri, strings.ReplaceAll(scope, " ", "+"))
if state != "" {
uri += "&state=" + state
}
http.Redirect(w, r, uri, http.StatusFound)
return
}
}
if !slices.Contains(client.RedirectUris, redirectUri) {
uri := fmt.Sprintf("%s?error=invalid_request&error_description=Redirect+URI+is+not+allowed", redirectUri)
if state != "" {
uri += "&state=" + state
}
http.Redirect(w, r, uri, http.StatusFound)
return
}
http.Redirect(w, r, fmt.Sprintf("/auth?%s", r.URL.Query().Encode()), http.StatusFound)
}

79
internal/oauth/code.go Normal file
View File

@ -0,0 +1,79 @@
package oauth
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"log"
"net/http"
"gitea.local/admin/hspguard/internal/cache"
"gitea.local/admin/hspguard/internal/util"
"gitea.local/admin/hspguard/internal/web"
"github.com/google/uuid"
)
func (h *OAuthHandler) getAuthCode(w http.ResponseWriter, r *http.Request) {
userId, ok := util.GetRequestUserId(r.Context())
if !ok {
web.Error(w, "failed to get user id from auth session", http.StatusInternalServerError)
return
}
user, err := h.repo.FindUserId(r.Context(), uuid.MustParse(userId))
if err != nil {
web.Error(w, "user with provided id does not exist", http.StatusUnauthorized)
return
}
type Request struct {
Nonce string `json:"nonce"`
ClientID string `json:"client_id"`
}
var req Request
decoder := json.NewDecoder(r.Body)
if err := decoder.Decode(&req); err != nil {
web.Error(w, "nonce field is required in request", http.StatusBadRequest)
return
}
buf := make([]byte, 32)
_, err = rand.Read(buf)
if err != nil {
log.Println("ERR: Failed to generate auth code:", err)
web.Error(w, "failed to create authorization code", http.StatusInternalServerError)
return
}
authCode := base64.RawURLEncoding.EncodeToString(buf)
params := cache.SaveAuthCodeParams{
AuthCode: authCode,
UserID: user.ID.String(),
ClientID: req.ClientID,
Nonce: req.Nonce,
}
log.Printf("DEBUG: Saving auth code session with params: %#v\n", params)
if err := h.cache.SaveAuthCode(r.Context(), &params); err != nil {
log.Println("ERR: Failed to save auth code in redis:", err)
web.Error(w, "failed to generate auth code", http.StatusInternalServerError)
return
}
type Response struct {
Code string `json:"code"`
}
encoder := json.NewEncoder(w)
w.Header().Set("Content-Type", "application/json")
if err := encoder.Encode(Response{
Code: authCode,
}); err != nil {
web.Error(w, "failed to encode response", http.StatusInternalServerError)
}
}

36
internal/oauth/jwks.go Normal file
View File

@ -0,0 +1,36 @@
package oauth
import (
"encoding/base64"
"encoding/json"
"net/http"
"gitea.local/admin/hspguard/internal/util"
"gitea.local/admin/hspguard/internal/web"
)
func (h *OAuthHandler) WriteJWKS(w http.ResponseWriter, r *http.Request) {
pubKey, err := util.ParseBase64PublicKey(h.cfg.Jwt.PublicKey)
if err != nil {
web.Error(w, "failed to parse public key", http.StatusInternalServerError)
}
n := base64.RawURLEncoding.EncodeToString(pubKey.N.Bytes())
e := base64.RawURLEncoding.EncodeToString([]byte{1, 0, 1}) // 65537 = 0x010001
jwks := map[string]interface{}{
"keys": []map[string]string{
{
"kty": "RSA",
"kid": "my-rsa-key-1",
"use": "sig",
"alg": "RS256",
"n": n,
"e": e,
},
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(jwks)
}

34
internal/oauth/openid.go Normal file
View File

@ -0,0 +1,34 @@
package oauth
import (
"encoding/json"
"net/http"
"gitea.local/admin/hspguard/internal/web"
)
func (h *OAuthHandler) OpenIdConfiguration(w http.ResponseWriter, r *http.Request) {
type Response struct {
TokenEndpoint string `json:"token_endpoint"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
JwksURI string `json:"jwks_uri"`
Issuer string `json:"issuer"`
EndSessionEndpoint string `json:"end_session_endpoint"`
}
w.Header().Set("Content-Type", "application/json")
encoder := json.NewEncoder(w)
w.Header().Set("Content-Type", "application/json")
if err := encoder.Encode(Response{
TokenEndpoint: h.cfg.Uri + "/api/v1/oauth/token",
AuthorizationEndpoint: h.cfg.Uri + "/api/v1/oauth/authorize",
JwksURI: h.cfg.Uri + "/.well-known/jwks.json",
Issuer: h.cfg.Uri,
EndSessionEndpoint: h.cfg.Uri + "/api/v1/oauth/logout",
}); err != nil {
web.Error(w, "failed to encode response", http.StatusInternalServerError)
}
}

36
internal/oauth/routes.go Normal file
View File

@ -0,0 +1,36 @@
package oauth
import (
"gitea.local/admin/hspguard/internal/cache"
"gitea.local/admin/hspguard/internal/config"
imiddleware "gitea.local/admin/hspguard/internal/middleware"
"gitea.local/admin/hspguard/internal/repository"
"github.com/go-chi/chi/v5"
)
type OAuthHandler struct {
repo *repository.Queries
cache *cache.Client
cfg *config.AppConfig
}
func NewOAuthHandler(repo *repository.Queries, cache *cache.Client, cfg *config.AppConfig) *OAuthHandler {
return &OAuthHandler{
repo,
cache,
cfg,
}
}
func (h *OAuthHandler) RegisterRoutes(router chi.Router) {
router.Route("/oauth", func(r chi.Router) {
r.Group(func(protected chi.Router) {
authMiddleware := imiddleware.NewAuthMiddleware(h.cfg)
protected.Use(authMiddleware.Runner)
protected.Post("/code", h.getAuthCode)
})
r.Get("/authorize", h.AuthorizeClient)
r.Post("/token", h.tokenEndpoint)
})
}

288
internal/oauth/token.go Normal file
View File

@ -0,0 +1,288 @@
package oauth
import (
"encoding/base64"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"time"
"gitea.local/admin/hspguard/internal/repository"
"gitea.local/admin/hspguard/internal/types"
"gitea.local/admin/hspguard/internal/util"
"gitea.local/admin/hspguard/internal/web"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
type ApiToken struct {
Token string
Expiration float64
}
type ApiTokens struct {
ID ApiToken
Access ApiToken
Refresh ApiToken
}
func (h *OAuthHandler) signApiTokens(user *repository.User, apiService *repository.ApiService, nonce *string) (*ApiTokens, error) {
accessExpiresIn := 15 * time.Minute
accessExpiresAt := time.Now().Add(accessExpiresIn)
accessClaims := types.ApiClaims{
Permissions: []string{},
RegisteredClaims: jwt.RegisteredClaims{
Issuer: h.cfg.Uri,
Subject: apiService.ClientID,
Audience: jwt.ClaimStrings{apiService.ClientID},
IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(accessExpiresAt),
},
}
access, err := util.SignJwtToken(accessClaims, h.cfg.Jwt.PrivateKey)
if err != nil {
return nil, err
}
var roles = []string{"user"}
if user.IsAdmin {
roles = append(roles, "admin")
}
idExpiresIn := 15 * time.Minute
idExpiresAt := time.Now().Add(idExpiresIn)
idClaims := types.IdTokenClaims{
Email: user.Email,
EmailVerified: user.EmailVerified,
Name: user.FullName,
Picture: user.ProfilePicture,
Nonce: nonce,
Roles: roles,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: h.cfg.Uri,
Subject: user.ID.String(),
Audience: jwt.ClaimStrings{apiService.ClientID},
IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(idExpiresAt),
},
}
idToken, err := util.SignJwtToken(idClaims, h.cfg.Jwt.PrivateKey)
if err != nil {
return nil, err
}
refreshExpiresIn := 24 * time.Hour
refreshExpiresAt := time.Now().Add(refreshExpiresIn)
refreshClaims := types.ApiRefreshClaims{
UserID: user.ID.String(),
RegisteredClaims: jwt.RegisteredClaims{
Issuer: h.cfg.Uri,
Subject: apiService.ClientID,
Audience: jwt.ClaimStrings{apiService.ClientID},
IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(refreshExpiresAt),
},
}
refresh, err := util.SignJwtToken(refreshClaims, h.cfg.Jwt.PrivateKey)
if err != nil {
return nil, err
}
return &ApiTokens{
ID: ApiToken{
Token: idToken,
Expiration: idExpiresIn.Seconds(),
},
Access: ApiToken{
Token: access,
Expiration: accessExpiresIn.Seconds(),
},
Refresh: ApiToken{
Token: refresh,
Expiration: refreshExpiresIn.Seconds(),
},
}, nil
}
func (h *OAuthHandler) tokenEndpoint(w http.ResponseWriter, r *http.Request) {
log.Println("[OAUTH] New request to token endpoint")
authHeader := r.Header.Get("Authorization")
if authHeader == "" || !strings.HasPrefix(authHeader, "Basic ") {
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Decode credentials
payload, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(authHeader, "Basic "))
if err != nil {
http.Error(w, "Invalid auth encoding", http.StatusBadRequest)
return
}
var clientId string
var clientSecret string
parts := strings.SplitN(string(payload), ":", 2)
if len(parts) != 2 {
http.Error(w, "Unauthorized", http.StatusForbidden)
return
}
clientId = parts[0]
clientSecret = parts[1]
log.Printf("Some client is trying to exchange code with id: %s and secret: %s\n", clientId, clientSecret)
// Parse the form data
err = r.ParseForm()
if err != nil {
http.Error(w, "Failed to parse form", http.StatusBadRequest)
return
}
grantType := r.FormValue("grant_type")
redirectUri := r.FormValue("redirect_uri")
log.Printf("Redirect URI is %s\n", redirectUri)
switch grantType {
case "authorization_code":
code := r.FormValue("code")
fmt.Printf("Code received: %s\n", code)
session, err := h.cache.GetAuthCode(r.Context(), code)
if err != nil {
log.Printf("ERR: Failed to find session under the code %s: %v\n", code, err)
web.Error(w, "no session found under this auth code", http.StatusNotFound)
return
}
log.Printf("DEBUG: Fetched code session: %#v\n", session)
apiService, err := h.repo.GetApiServiceCID(r.Context(), session.ClientID)
if err != nil {
log.Printf("ERR: Could not find API service with client %s: %v\n", session.ClientID, err)
web.Error(w, "service is not registered", http.StatusForbidden)
return
}
if session.ClientID != clientId {
web.Error(w, "invalid auth", http.StatusUnauthorized)
return
}
user, err := h.repo.FindUserId(r.Context(), uuid.MustParse(session.UserID))
if err != nil {
web.Error(w, "requested user not found", http.StatusNotFound)
return
}
tokens, err := h.signApiTokens(&user, &apiService, &session.Nonce)
if err != nil {
log.Println("ERR: Failed to sign api tokens:", err)
web.Error(w, "failed to sign tokens", http.StatusInternalServerError)
return
}
type Response struct {
IdToken string `json:"id_token"`
TokenType string `json:"token_type"`
AccessToken string `json:"access_token"`
Email string `json:"email"`
RefreshToken string `json:"refresh_token"`
ExpiresIn float64 `json:"expires_in"`
// TODO: add scope (RFC 8693 $2)
}
response := Response{
IdToken: tokens.ID.Token,
TokenType: "Bearer",
AccessToken: tokens.Access.Token,
RefreshToken: tokens.Refresh.Token,
ExpiresIn: tokens.Access.Expiration,
Email: user.Email,
}
log.Printf("sending following response: %#v\n", response)
w.Header().Set("Content-Type", "application/json")
encoder := json.NewEncoder(w)
if err := encoder.Encode(response); err != nil {
web.Error(w, "failed to encode response", http.StatusInternalServerError)
}
case "refresh_token":
refreshToken := r.FormValue("refresh_token")
var claims types.ApiRefreshClaims
token, err := util.VerifyToken(refreshToken, h.cfg.Jwt.PublicKey, &claims)
if err != nil || !token.Valid {
http.Error(w, fmt.Sprintf("invalid token: %v", err), http.StatusUnauthorized)
return
}
expire, err := claims.GetExpirationTime()
if err != nil {
web.Error(w, "failed to retrieve enough info from the token", http.StatusInternalServerError)
return
}
if time.Now().After(expire.Time) {
web.Error(w, "token is expired", http.StatusUnauthorized)
return
}
userID, err := uuid.Parse(claims.UserID)
if err != nil {
web.Error(w, "invalid user credentials in refresh token", http.StatusBadRequest)
return
}
user, err := h.repo.FindUserId(r.Context(), userID)
apiService, err := h.repo.GetApiServiceCID(r.Context(), claims.Subject)
if err != nil {
web.Error(w, "api service is not registered", http.StatusUnauthorized)
return
}
tokens, err := h.signApiTokens(&user, &apiService, nil)
type Response struct {
IdToken string `json:"id_token"`
TokenType string `json:"token_type"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn float64 `json:"expires_in"`
}
response := Response{
IdToken: tokens.ID.Token,
TokenType: "Bearer",
AccessToken: tokens.Access.Token,
RefreshToken: tokens.Refresh.Token,
ExpiresIn: tokens.Access.Expiration,
}
log.Printf("DEBUG: refresh - sending following response: %#v\n", response)
w.Header().Set("Content-Type", "application/json")
encoder := json.NewEncoder(w)
if err := encoder.Encode(response); err != nil {
web.Error(w, "failed to encode response", http.StatusInternalServerError)
}
default:
web.Error(w, "unsupported grant type", http.StatusBadRequest)
}
}

View File

@ -0,0 +1,241 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: api_services.sql
package repository
import (
"context"
"github.com/google/uuid"
)
const activateApiService = `-- name: ActivateApiService :exec
UPDATE api_services
SET is_active = true,
updated_at = NOW()
WHERE client_id = $1
`
func (q *Queries) ActivateApiService(ctx context.Context, clientID string) error {
_, err := q.db.Exec(ctx, activateApiService, clientID)
return err
}
const createApiService = `-- name: CreateApiService :one
INSERT INTO api_services (
client_id, client_secret, name, description, redirect_uris, scopes, grant_types, is_active
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8
) RETURNING id, client_id, client_secret, name, redirect_uris, scopes, grant_types, created_at, updated_at, is_active, description, icon_url
`
type CreateApiServiceParams struct {
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
Name string `json:"name"`
Description *string `json:"description"`
RedirectUris []string `json:"redirect_uris"`
Scopes []string `json:"scopes"`
GrantTypes []string `json:"grant_types"`
IsActive bool `json:"is_active"`
}
func (q *Queries) CreateApiService(ctx context.Context, arg CreateApiServiceParams) (ApiService, error) {
row := q.db.QueryRow(ctx, createApiService,
arg.ClientID,
arg.ClientSecret,
arg.Name,
arg.Description,
arg.RedirectUris,
arg.Scopes,
arg.GrantTypes,
arg.IsActive,
)
var i ApiService
err := row.Scan(
&i.ID,
&i.ClientID,
&i.ClientSecret,
&i.Name,
&i.RedirectUris,
&i.Scopes,
&i.GrantTypes,
&i.CreatedAt,
&i.UpdatedAt,
&i.IsActive,
&i.Description,
&i.IconUrl,
)
return i, err
}
const deactivateApiService = `-- name: DeactivateApiService :exec
UPDATE api_services
SET is_active = false,
updated_at = NOW()
WHERE client_id = $1
`
func (q *Queries) DeactivateApiService(ctx context.Context, clientID string) error {
_, err := q.db.Exec(ctx, deactivateApiService, clientID)
return err
}
const getApiServiceCID = `-- name: GetApiServiceCID :one
SELECT id, client_id, client_secret, name, redirect_uris, scopes, grant_types, created_at, updated_at, is_active, description, icon_url FROM api_services
WHERE client_id = $1
AND is_active = true
LIMIT 1
`
func (q *Queries) GetApiServiceCID(ctx context.Context, clientID string) (ApiService, error) {
row := q.db.QueryRow(ctx, getApiServiceCID, clientID)
var i ApiService
err := row.Scan(
&i.ID,
&i.ClientID,
&i.ClientSecret,
&i.Name,
&i.RedirectUris,
&i.Scopes,
&i.GrantTypes,
&i.CreatedAt,
&i.UpdatedAt,
&i.IsActive,
&i.Description,
&i.IconUrl,
)
return i, err
}
const getApiServiceId = `-- name: GetApiServiceId :one
SELECT id, client_id, client_secret, name, redirect_uris, scopes, grant_types, created_at, updated_at, is_active, description, icon_url FROM api_services
WHERE id = $1
LIMIT 1
`
func (q *Queries) GetApiServiceId(ctx context.Context, id uuid.UUID) (ApiService, error) {
row := q.db.QueryRow(ctx, getApiServiceId, id)
var i ApiService
err := row.Scan(
&i.ID,
&i.ClientID,
&i.ClientSecret,
&i.Name,
&i.RedirectUris,
&i.Scopes,
&i.GrantTypes,
&i.CreatedAt,
&i.UpdatedAt,
&i.IsActive,
&i.Description,
&i.IconUrl,
)
return i, err
}
const listApiServices = `-- name: ListApiServices :many
SELECT id, client_id, client_secret, name, redirect_uris, scopes, grant_types, created_at, updated_at, is_active, description, icon_url FROM api_services
ORDER BY created_at DESC
`
func (q *Queries) ListApiServices(ctx context.Context) ([]ApiService, error) {
rows, err := q.db.Query(ctx, listApiServices)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ApiService
for rows.Next() {
var i ApiService
if err := rows.Scan(
&i.ID,
&i.ClientID,
&i.ClientSecret,
&i.Name,
&i.RedirectUris,
&i.Scopes,
&i.GrantTypes,
&i.CreatedAt,
&i.UpdatedAt,
&i.IsActive,
&i.Description,
&i.IconUrl,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateApiService = `-- name: UpdateApiService :one
UPDATE api_services
SET
name = $2,
description = $3,
redirect_uris = $4,
scopes = $5,
grant_types = $6,
updated_at = NOW()
WHERE client_id = $1
RETURNING id, client_id, client_secret, name, redirect_uris, scopes, grant_types, created_at, updated_at, is_active, description, icon_url
`
type UpdateApiServiceParams struct {
ClientID string `json:"client_id"`
Name string `json:"name"`
Description *string `json:"description"`
RedirectUris []string `json:"redirect_uris"`
Scopes []string `json:"scopes"`
GrantTypes []string `json:"grant_types"`
}
func (q *Queries) UpdateApiService(ctx context.Context, arg UpdateApiServiceParams) (ApiService, error) {
row := q.db.QueryRow(ctx, updateApiService,
arg.ClientID,
arg.Name,
arg.Description,
arg.RedirectUris,
arg.Scopes,
arg.GrantTypes,
)
var i ApiService
err := row.Scan(
&i.ID,
&i.ClientID,
&i.ClientSecret,
&i.Name,
&i.RedirectUris,
&i.Scopes,
&i.GrantTypes,
&i.CreatedAt,
&i.UpdatedAt,
&i.IsActive,
&i.Description,
&i.IconUrl,
)
return i, err
}
const updateClientSecret = `-- name: UpdateClientSecret :exec
UPDATE api_services
SET client_secret = $2,
updated_at = NOW()
WHERE client_id = $1
`
type UpdateClientSecretParams struct {
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
}
func (q *Queries) UpdateClientSecret(ctx context.Context, arg UpdateClientSecretParams) error {
_, err := q.db.Exec(ctx, updateClientSecret, arg.ClientID, arg.ClientSecret)
return err
}

View File

@ -5,19 +5,39 @@
package repository
import (
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
type User struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
FullName string `json:"full_name"`
PasswordHash string `json:"password_hash"`
IsAdmin bool `json:"is_admin"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
LastLogin pgtype.Timestamptz `json:"last_login"`
PhoneNumber pgtype.Text `json:"phone_number"`
ProfilePicture pgtype.Text `json:"profile_picture"`
type ApiService struct {
ID uuid.UUID `json:"id"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
Name string `json:"name"`
RedirectUris []string `json:"redirect_uris"`
Scopes []string `json:"scopes"`
GrantTypes []string `json:"grant_types"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
IsActive bool `json:"is_active"`
Description *string `json:"description"`
IconUrl *string `json:"icon_url"`
}
type User struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
FullName string `json:"full_name"`
PasswordHash string `json:"password_hash"`
IsAdmin bool `json:"is_admin"`
CreatedAt *time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at"`
LastLogin *time.Time `json:"last_login"`
PhoneNumber *string `json:"phone_number"`
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

@ -9,11 +9,49 @@ import (
"context"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
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, avatar_verified, verified FROM users WHERE created_by = $1
`
func (q *Queries) FindAdminUsers(ctx context.Context, createdBy *uuid.UUID) ([]User, error) {
rows, err := q.db.Query(ctx, findAdminUsers, createdBy)
if err != nil {
return nil, err
}
defer rows.Close()
var items []User
for rows.Next() {
var i User
if err := rows.Scan(
&i.ID,
&i.Email,
&i.FullName,
&i.PasswordHash,
&i.IsAdmin,
&i.CreatedAt,
&i.UpdatedAt,
&i.LastLogin,
&i.PhoneNumber,
&i.ProfilePicture,
&i.CreatedBy,
&i.EmailVerified,
&i.AvatarVerified,
&i.Verified,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const findAllUsers = `-- name: FindAllUsers :many
SELECT id, email, full_name, password_hash, is_admin, created_at, updated_at, last_login, phone_number, profile_picture 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) {
@ -36,6 +74,10 @@ func (q *Queries) FindAllUsers(ctx context.Context) ([]User, error) {
&i.LastLogin,
&i.PhoneNumber,
&i.ProfilePicture,
&i.CreatedBy,
&i.EmailVerified,
&i.AvatarVerified,
&i.Verified,
); err != nil {
return nil, err
}
@ -48,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 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) {
@ -65,12 +107,16 @@ func (q *Queries) FindUserEmail(ctx context.Context, email string) (User, error)
&i.LastLogin,
&i.PhoneNumber,
&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 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) {
@ -87,24 +133,29 @@ func (q *Queries) FindUserId(ctx context.Context, id uuid.UUID) (User, error) {
&i.LastLogin,
&i.PhoneNumber,
&i.ProfilePicture,
&i.CreatedBy,
&i.EmailVerified,
&i.AvatarVerified,
&i.Verified,
)
return i, err
}
const insertUser = `-- name: InsertUser :one
INSERT INTO users (
email, full_name, password_hash, is_admin
email, full_name, password_hash, is_admin, created_by
) VALUES (
$1, $2, $3, $4
$1, $2, $3, $4, $5
)
RETURNING id
`
type InsertUserParams struct {
Email string `json:"email"`
FullName string `json:"full_name"`
PasswordHash string `json:"password_hash"`
IsAdmin bool `json:"is_admin"`
Email string `json:"email"`
FullName string `json:"full_name"`
PasswordHash string `json:"password_hash"`
IsAdmin bool `json:"is_admin"`
CreatedBy *uuid.UUID `json:"created_by"`
}
func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (uuid.UUID, error) {
@ -113,12 +164,24 @@ func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (uuid.UU
arg.FullName,
arg.PasswordHash,
arg.IsAdmin,
arg.CreatedBy,
)
var id uuid.UUID
err := row.Scan(&id)
return id, err
}
const updateLastLogin = `-- name: UpdateLastLogin :exec
UPDATE users
SET last_login = NOW()
WHERE id = $1
`
func (q *Queries) UpdateLastLogin(ctx context.Context, id uuid.UUID) error {
_, err := q.db.Exec(ctx, updateLastLogin, id)
return err
}
const updateProfilePicture = `-- name: UpdateProfilePicture :exec
UPDATE users
SET profile_picture = $1
@ -126,11 +189,44 @@ WHERE id = $2
`
type UpdateProfilePictureParams struct {
ProfilePicture pgtype.Text `json:"profile_picture"`
ID uuid.UUID `json:"id"`
ProfilePicture *string `json:"profile_picture"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) UpdateProfilePicture(ctx context.Context, arg UpdateProfilePictureParams) error {
_, err := q.db.Exec(ctx, updateProfilePicture, arg.ProfilePicture, arg.ID)
return err
}
const userVerifyAvatar = `-- name: UserVerifyAvatar :exec
UPDATE users
SET avatar_verified = true
WHERE id = $1
`
func (q *Queries) UserVerifyAvatar(ctx context.Context, id uuid.UUID) error {
_, err := q.db.Exec(ctx, userVerifyAvatar, id)
return err
}
const userVerifyComplete = `-- name: UserVerifyComplete :exec
UPDATE users
SET verified = true
WHERE id = $1
`
func (q *Queries) UserVerifyComplete(ctx context.Context, id uuid.UUID) error {
_, err := q.db.Exec(ctx, userVerifyComplete, id)
return err
}
const userVerifyEmail = `-- name: UserVerifyEmail :exec
UPDATE users
SET email_verified = true
WHERE id = $1
`
func (q *Queries) UserVerifyEmail(ctx context.Context, id uuid.UUID) error {
_, err := q.db.Exec(ctx, userVerifyEmail, id)
return err
}

View File

@ -5,8 +5,8 @@ import (
"io"
"log"
"net/url"
"os"
"gitea.local/admin/hspguard/internal/config"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
)
@ -15,27 +15,9 @@ type FileStorage struct {
client *minio.Client
}
func New() *FileStorage {
endpoint := os.Getenv("MINIO_ENDPOINT")
if endpoint == "" {
log.Fatalln("MINIO_ENDPOINT env var is required")
return nil
}
accessKey := os.Getenv("MINIO_ACCESS_KEY")
if accessKey == "" {
log.Fatalln("MINIO_ACCESS_KEY env var is required")
return nil
}
secretKey := os.Getenv("MINIO_SECRET_KEY")
if secretKey == "" {
log.Fatalln("MINIO_SECRET_KEY env var is required")
return nil
}
client, err := minio.New(endpoint, &minio.Options{
Creds: credentials.NewStaticV4(accessKey, secretKey, ""),
func New(cfg *config.AppConfig) *FileStorage {
client, err := minio.New(cfg.Minio.Endpoint, &minio.Options{
Creds: credentials.NewStaticV4(cfg.Minio.AccessKey, cfg.Minio.SecretKey, ""),
Secure: false,
})
if err != nil {
@ -52,6 +34,10 @@ func (fs *FileStorage) PutObject(ctx context.Context, bucketName string, objectN
return fs.client.PutObject(ctx, bucketName, objectName, reader, size, opts)
}
func (fs *FileStorage) GetObject(ctx context.Context, bucketName string, objectName string, opts minio.GetObjectOptions) (*minio.Object, error) {
return fs.client.GetObject(ctx, bucketName, objectName, opts)
}
func (fs *FileStorage) EndpointURL() *url.URL {
return fs.client.EndpointURL()
}

View File

@ -4,16 +4,30 @@ import "github.com/golang-jwt/jwt/v5"
type UserClaims struct {
UserEmail string `json:"user_email"`
IsAdmin bool `json:"is_admin"`
jwt.RegisteredClaims
}
type IdTokenClaims struct {
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Name string `json:"name"`
Picture *string `json:"picture"`
Nonce *string `json:"nonce"`
Roles []string `json:"roles"`
// TODO: add given_name, family_name, locale...
jwt.RegisteredClaims
}
type ApiClaims struct {
UserID string `json:"user_id"`
// Permissions are guard's defined permissions
// Examples:
// 1. User MetaData (specifically some fields like email, profile picture and name)
// 2. Actions on User, e.g. home permissions fetching, notifications emitting
// FIXME: correct permissions
Permissions []string `json:"permissions"`
// Subject is an API ID defined in guard's DB after registration
jwt.RegisteredClaims
// Subject = ClientID
}
type ApiRefreshClaims struct {
UserID string `json:"user_id"`
jwt.RegisteredClaims
// Subject = ClientID
}

40
internal/types/user.go Normal file
View File

@ -0,0 +1,40 @@
package types
import (
"time"
"gitea.local/admin/hspguard/internal/repository"
"github.com/google/uuid"
)
type UserDTO struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
FullName string `json:"full_name"`
IsAdmin bool `json:"is_admin"`
CreatedAt *time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at"`
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

@ -4,33 +4,21 @@ import (
"context"
"fmt"
"log"
"os"
"gitea.local/admin/hspguard/internal/config"
"gitea.local/admin/hspguard/internal/repository"
"gitea.local/admin/hspguard/internal/util"
"github.com/google/uuid"
)
func EnsureAdminUser(ctx context.Context, repo *repository.Queries) {
adminName := os.Getenv("ADMIN_NAME")
if adminName == "" {
adminName = "admin"
}
adminEmail := os.Getenv("ADMIN_EMAIL")
adminPassword := os.Getenv("ADMIN_PASSWORD")
if adminEmail == "" {
log.Fatalln("ERR: ADMIN_EMAIL env variable is required")
}
_, err := repo.FindUserEmail(ctx, adminEmail)
func EnsureAdminUser(ctx context.Context, cfg *config.AppConfig, repo *repository.Queries) {
_, err := repo.FindUserEmail(ctx, cfg.Admin.Email)
if err != nil {
if adminPassword == "" {
if cfg.Admin.Password == "" {
log.Fatalln("ERR: ADMIN_PASSWORD env variable is required")
}
if _, err := createAdmin(ctx, adminName, adminEmail, adminPassword, repo); err != nil {
if _, err := createAdmin(ctx, cfg.Admin.Name, cfg.Admin.Email, cfg.Admin.Password, repo); err != nil {
log.Fatalln("ERR: Failed to create admin account:", err)
}
}

View File

@ -4,36 +4,48 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"path/filepath"
"strings"
"time"
"gitea.local/admin/hspguard/internal/config"
imiddleware "gitea.local/admin/hspguard/internal/middleware"
"gitea.local/admin/hspguard/internal/repository"
"gitea.local/admin/hspguard/internal/storage"
"gitea.local/admin/hspguard/internal/util"
"gitea.local/admin/hspguard/internal/web"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/minio/minio-go/v7"
)
type UserHandler struct {
repo *repository.Queries
minio *storage.FileStorage
cfg *config.AppConfig
}
func NewUserHandler(repo *repository.Queries, minio *storage.FileStorage) *UserHandler {
func NewUserHandler(repo *repository.Queries, minio *storage.FileStorage, cfg *config.AppConfig) *UserHandler {
return &UserHandler{
repo: repo,
minio: minio,
repo,
minio,
cfg,
}
}
func (h *UserHandler) RegisterRoutes(api chi.Router) {
api.Group(func(protected chi.Router) {
authMiddleware := imiddleware.NewAuthMiddleware(h.cfg)
protected.Use(authMiddleware.Runner)
protected.Put("/avatar", h.uploadAvatar)
})
api.Post("/register", h.register)
api.Put("/avatar", h.uploadAvatar)
api.Get("/avatar/{avatar}", h.getAvatar)
}
type RegisterParams struct {
@ -86,6 +98,8 @@ func (h *UserHandler) register(w http.ResponseWriter, r *http.Request) {
Id string `json:"id"`
}
w.Header().Set("Content-Type", "application/json")
if err := encoder.Encode(Response{
Id: id.String(),
}); err != nil {
@ -93,6 +107,28 @@ func (h *UserHandler) register(w http.ResponseWriter, r *http.Request) {
}
}
func (h *UserHandler) getAvatar(w http.ResponseWriter, r *http.Request) {
avatarObject := chi.URLParam(r, "avatar")
object, err := h.minio.GetObject(r.Context(), "guard-storage", avatarObject, minio.GetObjectOptions{})
if err != nil {
web.Error(w, "avatar not found", http.StatusNotFound)
return
}
defer object.Close()
stat, err := object.Stat()
if err != nil {
log.Printf("ERR: failed to get object stats: %v\n", err)
web.Error(w, "failed to get avatar", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", stat.ContentType)
w.WriteHeader(http.StatusOK)
io.Copy(w, object)
}
func (h *UserHandler) uploadAvatar(w http.ResponseWriter, r *http.Request) {
userId, ok := util.GetRequestUserId(r.Context())
if !ok {
@ -130,23 +166,29 @@ func (h *UserHandler) uploadAvatar(w http.ResponseWriter, r *http.Request) {
ContentType: header.Header.Get("Content-Type"),
})
if err != nil {
log.Println("ERR: Failed to put object:", err)
web.Error(w, "failed to upload image", http.StatusInternalServerError)
return
}
imageURL := fmt.Sprintf("http://%s/%s/%s", h.minio.EndpointURL().Host, "guard-storage", uploadInfo.Key)
imgURI := fmt.Sprintf("%s/api/v1/avatar/%s", h.cfg.Uri, uploadInfo.Key)
if err := h.repo.UpdateProfilePicture(r.Context(), repository.UpdateProfilePictureParams{
ProfilePicture: pgtype.Text{
String: imageURL,
Valid: true,
},
ID: user.ID,
ProfilePicture: &imgURI,
ID: user.ID,
}); err != nil {
web.Error(w, "failed to update profile picture", http.StatusInternalServerError)
return
}
if !user.AvatarVerified {
if err := h.repo.UserVerifyAvatar(r.Context(), user.ID); err != nil {
log.Println("ERR: Failed to update avatar_verified:", err)
web.Error(w, "failed to verify avatar", http.StatusInternalServerError)
return
}
}
type Response struct {
URL string `json:"url"`
}
@ -155,7 +197,9 @@ func (h *UserHandler) uploadAvatar(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
encoder := json.NewEncoder(w)
if err := encoder.Encode(Response{URL: imageURL}); err != nil {
w.Header().Set("Content-Type", "application/json")
if err := encoder.Encode(Response{URL: fmt.Sprintf("%s/avatar/%s", h.cfg.Uri, uploadInfo.Key)}); err != nil {
web.Error(w, "failed to write response", http.StatusInternalServerError)
}
}

9
internal/util/client.go Normal file
View File

@ -0,0 +1,9 @@
package util
func GenerateClientID() (string, error) {
return generateRandomStringURLSafe(16)
}
func GenerateClientSecret() (string, error) {
return generateRandomStringURLSafe(32)
}

17
internal/util/generate.go Normal file
View File

@ -0,0 +1,17 @@
package util
import (
"crypto/rand"
"encoding/base64"
"fmt"
)
// generateRandomStringURLSafe generates a base64 URL-safe random string of n bytes.
func generateRandomStringURLSafe(n int) (string, error) {
bytes := make([]byte, n)
_, err := rand.Read(bytes)
if err != nil {
return "", fmt.Errorf("failed to generate random bytes: %w", err)
}
return base64.RawURLEncoding.EncodeToString(bytes), nil
}

81
internal/util/jwt.go Normal file
View File

@ -0,0 +1,81 @@
package util
import (
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"fmt"
"github.com/golang-jwt/jwt/v5"
)
func ParseBase64PrivateKey(b64 string) (*rsa.PrivateKey, error) {
decoded, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
return nil, fmt.Errorf("failed to decode base64 key: %v", err)
}
key, err := x509.ParsePKCS8PrivateKey(decoded)
return key.(*rsa.PrivateKey), err
}
func ParseBase64PublicKey(b64 string) (*rsa.PublicKey, error) {
decoded, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
return nil, fmt.Errorf("failed to decode base64 key: %v", err)
}
pubInterface, err := x509.ParsePKIXPublicKey(decoded)
if err != nil {
return nil, fmt.Errorf("failed to parse public key: %v", err)
}
pubKey, ok := pubInterface.(*rsa.PublicKey)
if !ok {
return nil, fmt.Errorf("not an RSA public key")
}
return pubKey, nil
}
func SignJwtToken(claims jwt.Claims, key string) (string, error) {
privateKey, err := ParseBase64PrivateKey(key)
if err != nil {
return "", err
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
token.Header["kid"] = "my-rsa-key-1"
s, err := token.SignedString(privateKey)
if err != nil {
return "", err
}
return s, nil
}
func VerifyToken(token string, key string, claims jwt.Claims) (*jwt.Token, error) {
publicKey, err := ParseBase64PublicKey(key)
if err != nil {
return nil, err
}
parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return publicKey, nil
})
if err != nil {
return nil, fmt.Errorf("invalid token: %w", err)
}
if !parsed.Valid {
return nil, fmt.Errorf("token is not valid")
}
return parsed, nil
}

View File

@ -0,0 +1,27 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE api_services (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Unique identifier
-- OIDC-required fields
client_id TEXT UNIQUE NOT NULL,
client_secret TEXT NOT NULL, -- Store as hashed value
-- Metadata
name TEXT NOT NULL,
redirect_uris TEXT[] DEFAULT '{}',
scopes TEXT[] DEFAULT '{openid}',
grant_types TEXT[] DEFAULT '{authorization_code}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
is_active BOOLEAN NOT NULL DEFAULT true
);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE api_services;
-- +goose StatementEnd

View File

@ -0,0 +1,11 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE api_services ADD description TEXT DEFAULT '';
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE api_services
DROP COLUMN description;
-- +goose StatementEnd

View File

@ -0,0 +1,12 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE users
ADD COLUMN created_by UUID REFERENCES users (id) ON DELETE SET NULL;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE users
DROP COLUMN created_by;
-- +goose StatementEnd

View File

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

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

View File

@ -0,0 +1,12 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE api_services
ADD COLUMN icon_url TEXT DEFAULT NULL;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE api_services
DROP COLUMN icon_url;
-- +goose StatementEnd

51
queries/api_services.sql Normal file
View File

@ -0,0 +1,51 @@
-- name: CreateApiService :one
INSERT INTO api_services (
client_id, client_secret, name, description, redirect_uris, scopes, grant_types, is_active
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8
) RETURNING *;
-- name: GetApiServiceCID :one
SELECT * FROM api_services
WHERE client_id = $1
AND is_active = true
LIMIT 1;
-- name: GetApiServiceId :one
SELECT * FROM api_services
WHERE id = $1
LIMIT 1;
-- name: ListApiServices :many
SELECT * FROM api_services
ORDER BY created_at DESC;
-- name: UpdateApiService :one
UPDATE api_services
SET
name = $2,
description = $3,
redirect_uris = $4,
scopes = $5,
grant_types = $6,
updated_at = NOW()
WHERE client_id = $1
RETURNING *;
-- name: DeactivateApiService :exec
UPDATE api_services
SET is_active = false,
updated_at = NOW()
WHERE client_id = $1;
-- name: ActivateApiService :exec
UPDATE api_services
SET is_active = true,
updated_at = NOW()
WHERE client_id = $1;
-- name: UpdateClientSecret :exec
UPDATE api_services
SET client_secret = $2,
updated_at = NOW()
WHERE client_id = $1;

View File

@ -1,11 +1,14 @@
-- name: FindAllUsers :many
SELECT * FROM users;
-- name: FindAdminUsers :many
SELECT * FROM users WHERE created_by = $1;
-- name: InsertUser :one
INSERT INTO users (
email, full_name, password_hash, is_admin
email, full_name, password_hash, is_admin, created_by
) VALUES (
$1, $2, $3, $4
$1, $2, $3, $4, $5
)
RETURNING id;
@ -19,3 +22,23 @@ SELECT * FROM users WHERE id = $1 LIMIT 1;
UPDATE users
SET profile_picture = $1
WHERE id = $2;
-- name: UserVerifyEmail :exec
UPDATE users
SET email_verified = true
WHERE id = $1;
-- name: UserVerifyAvatar :exec
UPDATE users
SET avatar_verified = true
WHERE id = $1;
-- name: UserVerifyComplete :exec
UPDATE users
SET verified = true
WHERE id = $1;
-- name: UpdateLastLogin :exec
UPDATE users
SET last_login = NOW()
WHERE id = $1;

4
redis.conf Normal file
View File

@ -0,0 +1,4 @@
# Enable ACL
user default off
user guard on >guard allcommands allkeys

View File

@ -1,16 +1,17 @@
# Generate private key
openssl ecparam -genkey -name prime256v1 -noout -out ec256-private.pem
# Generate 2048-bit RSA private key (suppress output)
openssl genpkey -algorithm RSA -out rsa-private.pem -pkeyopt rsa_keygen_bits:2048 *> $null
# Extract public key
openssl ec -in ec256-private.pem -pubout -out ec256-public.pem
# Extract the public key from the private key (suppress output)
openssl rsa -in rsa-private.pem -pubout -out rsa-public.pem *> $null
Write-Host ""
Write-Host "Private Key (DER base64):"
openssl ec -in ec256-private.pem -outform DER | openssl base64 -A
Write-Host ""
Write-Host "--------------------------------"
Write-Host ""
# Base64 encode private key (DER format, for JWT_PRIVATE_KEY)
Write-Host -NoNewline 'JWT_PRIVATE_KEY="'
openssl pkcs8 -topk8 -nocrypt -in rsa-private.pem -outform DER 2>$null | openssl base64 -A
Write-Host '"'
Write-Host "Public Key (DER base64):"
openssl ec -in ec256-private.pem -pubout -outform DER | openssl base64 -A
# Base64 encode public key (DER format, for JWT_PUBLIC_KEY)
Write-Host -NoNewline 'JWT_PUBLIC_KEY="'
openssl rsa -in rsa-private.pem -pubout -outform DER 2>$null | openssl base64 -A
Write-Host '"'

View File

@ -1,26 +1,19 @@
#!/bin/bash
# Generate private key
# openssl ecparam -genkey -name prime256v1 -noout -out ec256-private.pem
# openssl ec -in ec256-private.pem -outform DER | base64 -w 0
# Generate 2048-bit RSA private key (suppress all output)
openssl genpkey -algorithm RSA -out rsa-private.pem -pkeyopt rsa_keygen_bits:2048 >/dev/null 2>&1
# Extract public key
# openssl ec -in ec256-private.pem -pubout -out ec256-public.pem
# openssl ec -in ec256-private.pem -pubout -outform DER | base64 -w 0
# Generate private key
openssl ecparam -genkey -name prime256v1 -noout -out ec256-private.pem
# Extract public key
openssl ec -in ec256-private.pem -pubout -out ec256-public.pem
# Extract the public key from the private key (suppress all output)
openssl rsa -in rsa-private.pem -pubout -out rsa-public.pem >/dev/null 2>&1
echo ""
echo "Private Key (DER base64):"
openssl ec -in ec256-private.pem -outform DER | base64 -w 0
echo "
--------------------------------"
# Base64 encode private key (for JWT_PRIVATE_KEY)
echo -n 'JWT_PRIVATE_KEY="'
openssl pkcs8 -topk8 -nocrypt -in rsa-private.pem -outform DER 2>/dev/null | base64 -w 0
echo '"'
echo ""
echo "Public Key (DER base64):"
openssl ec -in ec256-private.pem -pubout -outform DER | base64 -w 0
# Base64 encode public key (for JWT_PUBLIC_KEY)
echo -n 'JWT_PUBLIC_KEY="'
openssl rsa -in rsa-private.pem -pubout -outform DER 2>/dev/null | base64 -w 0
echo '"'

View File

@ -1,4 +1,3 @@
version: "2"
sql:
- engine: "postgresql"
@ -14,8 +13,95 @@ sql:
- db_type: "uuid"
go_type:
import: "github.com/google/uuid"
type: "UUID"
- db_type: "timestamptz"
type: UUID
- db_type: "uuid"
nullable: true
go_type:
import: "github.com/google/uuid"
type: UUID
pointer: true
# ───── bool ──────────────────────────────────────────
- db_type: "pg_catalog.bool" # or just "bool"
go_type: { type: "bool" }
- db_type: "bool" # or just "bool"
go_type: { type: "bool" }
- db_type: "pg_catalog.bool"
nullable: true
go_type:
type: "bool"
pointer: true # ⇒ *bool for NULLable columns
- db_type: "bool"
nullable: true
go_type:
type: "bool"
pointer: true # ⇒ *bool for NULLable columns
# ───── text ──────────────────────────────────────────
- db_type: "pg_catalog.text"
go_type: { type: "string" }
- db_type: "text" # or just "bool"
go_type: { type: "string" }
- db_type: "pg_catalog.text"
nullable: true
go_type:
type: "string"
pointer: true # ⇒ *bool for NULLable columns
- db_type: "text"
nullable: true
go_type:
type: "string"
pointer: true # ⇒ *bool for NULLable columns
# ───── timestamp (WITHOUT TZ) ────────────────────────
- db_type: "pg_catalog.timestamp" # or "timestamp"
go_type:
import: "time"
type: "Time"
- db_type: "timestamp" # or "timestamp"
go_type:
import: "time"
type: "Time"
- db_type: "pg_catalog.timestamp"
nullable: true
go_type:
import: "time"
type: "Time"
pointer: true
- db_type: "timestamp"
nullable: true
go_type:
import: "time"
type: "Time"
pointer: true
# ───── timestamptz (WITH TZ) ─────────────────────────
- db_type: "pg_catalog.timestamptz" # or "timestamptz"
go_type:
import: "time"
type: "Time"
- db_type: "timestamptz" # or "timestamptz"
go_type:
import: "time"
type: "Time"
- db_type: "pg_catalog.timestamptz"
nullable: true
go_type:
import: "time"
type: "Time"
pointer: true
- db_type: "timestamptz"
nullable: true
go_type:
import: "time"
type: "Time"
pointer: true

5
web/.prettierignore Normal file
View File

@ -0,0 +1,5 @@
# Ignore artifacts:
build
coverage
node_modules
public

1
web/.prettierrc Normal file
View File

@ -0,0 +1 @@
{}

View File

@ -24,31 +24,31 @@ export default tseslint.config({
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
project: ["./tsconfig.node.json", "./tsconfig.app.json"],
tsconfigRootDir: import.meta.dirname,
},
},
})
});
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
import reactX from "eslint-plugin-react-x";
import reactDom from "eslint-plugin-react-dom";
export default tseslint.config({
plugins: {
// Add the react-x and react-dom plugins
'react-x': reactX,
'react-dom': reactDom,
"react-x": reactX,
"react-dom": reactDom,
},
rules: {
// other rules...
// Enable its recommended typescript rules
...reactX.configs['recommended-typescript'].rules,
...reactX.configs["recommended-typescript"].rules,
...reactDom.configs.recommended.rules,
},
})
});
```

View File

@ -1,28 +1,28 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ['dist'] },
{ ignores: ["dist"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
},
},
)
);

6
web/globals.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
interface Window {
guard: {
refreshing: boolean;
refreshQueue: ((token: string | null) => void)[];
};
}

View File

@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
@ -8,6 +8,7 @@
</head>
<body>
<div id="root"></div>
<div id="portal-root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

659
web/package-lock.json generated
View File

@ -8,16 +8,19 @@
"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",
"lucide-react": "^0.511.0",
"next-themes": "^0.4.6",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.56.4",
"react-icons": "^5.5.0",
"react-router": "^7.6.0",
"tailwindcss": "^4.1.7"
"react-jwt": "^1.3.0",
"react-router": "^7.6.1",
"tailwindcss": "^4.1.7",
"zustand": "^5.0.5"
},
"devDependencies": {
"@eslint/js": "^9.25.0",
@ -30,6 +33,7 @@
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"path": "^0.12.7",
"prettier": "3.5.3",
"sass": "^1.89.0",
"typescript": "~5.8.3",
"typescript-eslint": "^8.30.1",
@ -53,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",
@ -108,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",
@ -141,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",
@ -182,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"
@ -191,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"
@ -224,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"
@ -267,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",
@ -294,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",
@ -312,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"
@ -321,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",
@ -330,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",
@ -2063,17 +1948,11 @@
"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",
"integrity": "sha512-EB1yiiYdvySuIITtD5lhW4yPyJ31RkJkkDw794LaQYrxCSaQV/47y5o1FMC4zF9ZyjUjzJMZwbovEnT5yHTW6g==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
@ -2401,19 +2280,21 @@
"dev": true,
"license": "Python-2.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==",
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz",
"integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.5",
"cosmiconfig": "^7.0.0",
"resolve": "^1.19.0"
},
"engines": {
"node": ">=10",
"npm": ">=6"
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/balanced-match": {
@ -2480,10 +2361,24 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/callsites": {
"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"
@ -2572,6 +2467,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@ -2595,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",
@ -2639,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"
@ -2665,6 +2549,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/detect-libc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
@ -2678,6 +2571,20 @@
"node": ">=0.10"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.155",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.155.tgz",
@ -2698,13 +2605,49 @@
"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==",
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"is-arrayish": "^0.2.1"
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": {
@ -2761,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"
@ -3034,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",
@ -3078,6 +3016,41 @@
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
"integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -3111,6 +3084,43 @@
"node": ">=6.9.0"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@ -3137,6 +3147,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@ -3160,6 +3182,33 @@
"node": ">=8"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@ -3172,14 +3221,11 @@
"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",
"integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==",
"license": "ISC"
},
"node_modules/ignore": {
"version": "5.3.2",
@ -3202,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",
@ -3231,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",
@ -3305,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": {
@ -3324,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"
@ -3339,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",
@ -3633,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",
@ -3690,6 +3706,15 @@
"@jridgewell/sourcemap-codec": "^1.5.0"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@ -3714,6 +3739,27 @@
"node": ">=8.6"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@ -3767,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": {
@ -3872,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"
@ -3880,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",
@ -3929,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",
@ -4001,6 +4016,22 @@
"node": ">= 0.8.0"
}
},
"node_modules/prettier": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz",
"integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
@ -4011,6 +4042,12 @@
"node": ">= 0.6.0"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@ -4088,11 +4125,20 @@
"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",
"integrity": "sha512-aC+X6q8pi63zoO7A060/4mfF5jM6Ay+4YyY4QgdD8dDOqp89sPcg0IhWEHyPACnVETMjBWzmxMPgIPosQNeYyw==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"optionalDependencies": {
"fsevents": "^2.3.2"
},
"peerDependencies": {
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
@ -4105,9 +4151,9 @@
}
},
"node_modules/react-router": {
"version": "7.6.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.0.tgz",
"integrity": "sha512-GGufuHIVCJDbnIAXP3P9Sxzq3UUsddG3rrI3ut1q6m0FI6vxVBF3JoPQ38+W/blslLH4a5Yutp8drkEpXoddGQ==",
"version": "7.6.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.1.tgz",
"integrity": "sha512-hPJXXxHJZEsPFNVbtATH7+MMX43UDeOauz+EAU4cgqTn7ojdI9qQORqS8Z0qmDlL1TclO/6jLRYUEtbWidtdHQ==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
@ -4140,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"
@ -4309,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",
@ -4340,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",
@ -4359,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",
@ -4754,6 +4754,35 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zustand": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.5.tgz",
"integrity": "sha512-mILtRfKW9xM47hqxGIxCv12gXusoY/xTSHBYApXozR0HmQv299whhBeeAcRy+KrPPybzosvJBCOmVjq6x12fCg==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
}
}
}

View File

@ -5,21 +5,25 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build --watch",
"build": "tsc -b && vite build",
"build:watch": "tsc -b && vite build --watch",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@tailwindcss/vite": "^4.1.7",
"axios": "^1.9.0",
"idb": "^8.0.3",
"lucide-react": "^0.511.0",
"next-themes": "^0.4.6",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.56.4",
"react-icons": "^5.5.0",
"react-router": "^7.6.0",
"tailwindcss": "^4.1.7"
"react-jwt": "^1.3.0",
"react-router": "^7.6.1",
"tailwindcss": "^4.1.7",
"zustand": "^5.0.5"
},
"devDependencies": {
"@eslint/js": "^9.25.0",
@ -32,6 +36,7 @@
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"path": "^0.12.7",
"prettier": "3.5.3",
"sass": "^1.89.0",
"typescript": "~5.8.3",
"typescript-eslint": "^8.30.1",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 MiB

After

Width:  |  Height:  |  Size: 59 KiB

BIN
web/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

View File

@ -1,53 +1,145 @@
import { useEffect, type FC } from "react";
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import { type FC } from "react";
import { createBrowserRouter, Navigate, RouterProvider } from "react-router";
import IndexPage from "./pages/Index";
import LoginPage from "./pages/Login";
import RegisterPage from "./pages/Register";
import { useDbContext } from "./context/db/db";
import { openDB } from "idb";
import AgreementPage from "./pages/Agreement";
import AuthorizePage from "./pages/Authorize";
import AuthenticatePage from "./pages/Authenticate";
import AuthLayout from "./layout/AuthLayout";
import DashboardLayout from "./layout/DashboardLayout";
import PersonalInfoPage from "./pages/PersonalInfo";
import ApiServicesPage from "./pages/Admin/ApiServices";
import AdminLayout from "./layout/AdminLayout";
import ApiServiceCreatePage from "./pages/Admin/ApiServices/Create";
import ViewApiServicePage from "./pages/Admin/ApiServices/View";
import NotAllowedPage from "./pages/NotAllowed";
import NotFoundPage from "./pages/NotFound";
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([
{
path: "/",
element: <IndexPage />,
element: <AuthLayout />,
children: [
{
path: "/",
element: <DashboardLayout />,
children: [
{
index: true,
element: <IndexPage />,
},
{
path: "personal-info",
element: <PersonalInfoPage />,
},
{
path: "admin",
element: <AdminLayout />,
children: [
{
index: true,
element: <Navigate to="/admin/api-services" />,
},
{
path: "api-services",
children: [
{ index: true, element: <ApiServicesPage /> },
{ path: "create", element: <ApiServiceCreatePage /> },
{
path: "view/:serviceId",
element: <ViewApiServicePage />,
},
{
path: "edit/:serviceId",
element: <ApiServiceEditPage />,
},
],
},
{
path: "users",
children: [
{ index: true, element: <AdminUsersPage /> },
{ path: "create", element: <AdminCreateUserPage /> },
{
path: "view/:userId",
element: <AdminViewUserPage />,
},
// {
// path: "edit/:serviceId",
// element: <ApiServiceEditPage />,
// },
],
},
],
},
],
},
],
},
{
path: "/agreement",
element: <AgreementPage />,
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: "/login",
element: <LoginPage />,
path: "/auth",
element: <AuthLayout />,
children: [
{ index: true, element: <AuthorizePage /> },
{ path: "login", element: <LoginPage /> },
{ path: "register", element: <RegisterPage /> },
{ path: "authenticate", element: <AuthenticatePage /> },
],
},
{
path: "/register",
element: <RegisterPage />,
path: "/not-allowed",
element: <NotAllowedPage />,
},
{
path: "*",
element: <NotFoundPage />,
},
]);
const App: FC = () => {
const { db, setDb } = useDbContext();
useEffect(() => {
const openConnection = async () => {
const dbPromise = openDB("guard-local", 3, {
upgrade: (db) => {
if (!db.objectStoreNames.contains("accounts")) {
db.createObjectStore("accounts", { keyPath: "accountId" });
}
},
});
const conn = await dbPromise;
setDb(conn);
};
openConnection();
}, [db, setDb]);
return <RouterProvider router={router} />;
};

View File

@ -0,0 +1,104 @@
import type { ApiService, ApiServiceCredentials } from "@/types";
import { axios, handleApiError } from "..";
export interface FetchApiServicesResponse {
items: ApiService[];
count: number;
}
export const getApiServices = async (): Promise<FetchApiServicesResponse> => {
const response = await axios.get<FetchApiServicesResponse>(
"/api/v1/admin/api-services",
);
if (response.status !== 200 && response.status !== 201)
throw await handleApiError(response);
return response.data;
};
export interface CreateApiServiceRequest {
name: string;
description: string;
redirect_uris: string[];
scopes: string[];
grant_types: string[];
is_active: boolean;
}
export interface CreateApiServiceResponse {
service: ApiService;
credentials: ApiServiceCredentials;
}
export const postApiService = async (
req: CreateApiServiceRequest,
): Promise<CreateApiServiceResponse> => {
const response = await axios.post<CreateApiServiceResponse>(
"/api/v1/admin/api-services",
req,
);
if (response.status !== 200 && response.status !== 201)
throw await handleApiError(response);
return response.data;
};
export const getApiService = async (id: string): Promise<ApiService> => {
const response = await axios.get<ApiService>(
`/api/v1/admin/api-services/${id}`,
);
if (response.status !== 200 && response.status !== 201)
throw await handleApiError(response);
return response.data;
};
export const getApiServiceCID = async (
clientId: string,
): Promise<ApiService> => {
const response = await axios.get<ApiService>(
`/api/v1/api-services/client/${clientId}`,
);
if (response.status !== 200 && response.status !== 201)
throw await handleApiError(response);
return response.data;
};
export const patchToggleApiService = async (id: string): Promise<void> => {
const response = await axios.patch(`/api/v1/admin/api-services/toggle/${id}`);
if (response.status !== 200 && response.status !== 201)
throw await handleApiError(response);
return response.data;
};
export interface UpdateApiServiceRequest {
name: string;
description: string;
redirect_uris: string[];
scopes: string[];
grant_types: string[];
}
export type UpdateApiServiceResponse = ApiService;
export const putApiService = async (
serviceId: string,
req: UpdateApiServiceRequest,
): Promise<UpdateApiServiceResponse> => {
const response = await axios.put<UpdateApiServiceResponse>(
`/api/v1/admin/api-services/${serviceId}`,
req,
);
if (response.status !== 200 && response.status !== 201)
throw await handleApiError(response);
return response.data;
};

View File

@ -0,0 +1,56 @@
import type { UserProfile } from "@/types";
import { axios, handleApiError } from "..";
export interface FetchUsersResponse {
items: UserProfile[];
count: number;
}
export const adminGetUsersApi = async (): Promise<FetchUsersResponse> => {
const response = await axios.get<FetchUsersResponse>("/api/v1/admin/users");
if (response.status !== 200 && response.status !== 201)
throw await handleApiError(response);
return response.data;
};
export type FetchUserResponse = UserProfile;
export const adminGetUserApi = async (
id: string,
): Promise<FetchUserResponse> => {
const response = await axios.get<FetchUserResponse>(
`/api/v1/admin/users/${id}`,
);
if (response.status !== 200 && response.status !== 201)
throw await handleApiError(response);
return response.data;
};
export interface CreateUserRequest {
email: string;
full_name: string;
password: string;
is_admin: boolean;
}
export interface CreateUserResponse {
id: string;
}
export const postUser = async (
req: CreateUserRequest,
): Promise<CreateUserResponse> => {
const response = await axios.post<CreateUserResponse>(
"/api/v1/admin/users",
req,
);
if (response.status !== 200 && response.status !== 201)
throw await handleApiError(response);
return response.data;
};

17
web/src/api/avatar.ts Normal file
View File

@ -0,0 +1,17 @@
import { axios, handleApiError } from ".";
export const uploadAvatarApi = async (imageFile: File): Promise<string> => {
const formData = new FormData();
formData.append("image", imageFile);
const response = await axios.put("/api/v1/avatar", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
if (response.status !== 200 && response.status !== 201)
throw await handleApiError(response);
return response.data;
};

29
web/src/api/code.ts Normal file
View File

@ -0,0 +1,29 @@
import { handleApiError, axios } from ".";
export interface CodeResponse {
code: string;
}
export const codeApi = async (
accessToken: string,
nonce: string,
clientId: string,
) => {
const response = await axios.post(
"/api/v1/oauth/code",
{ nonce, client_id: clientId },
{
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
},
);
if (response.status !== 200 && response.status !== 201)
throw await handleApiError(response);
const data: CodeResponse = response.data;
return data;
};

View File

@ -1,20 +1,121 @@
export const handleApiError = async (response: Response) => {
try {
const json = await response.json();
console.log({ json });
const text = json.error ?? "unexpected error happpened";
return new Error(text[0].toUpperCase() + text.slice(1));
} catch (err) {
try {
console.log(err);
const text = await response.text();
if (text.length > 0) {
return new Error(text[0].toUpperCase() + text.slice(1));
}
} catch (err) {
console.log(err);
}
import { deleteAccount, updateAccountTokens } from "@/repository/account";
import { useDbStore } from "@/store/db";
import { useAuth } from "@/store/auth";
import Axios, { type AxiosResponse } from "axios";
import { refreshTokenApi } from "./refresh";
import { isExpired } from "react-jwt";
export const axios = Axios.create({
headers: {
"Content-Type": "application/json",
},
});
let refreshQueue: ((token: string | null) => void)[] = [];
const waitForTokenRefresh = () => {
return new Promise<string | null>((resolve) => {
refreshQueue.push((token: string | null) => resolve(token));
});
};
const processRefreshQueue = async (token: string | null) => {
refreshQueue.forEach((resolve) => resolve(token));
refreshQueue = [];
};
const logout = async (accountId: string) => {
const db = useDbStore.getState().db;
const requireSignIn = useAuth.getState().requireSignIn;
if (db) {
await deleteAccount(db, accountId);
}
requireSignIn?.();
};
const refreshToken = async (
accountId: string,
refreshToken: string,
): Promise<{ access: string; refresh: string }> => {
const db = useDbStore.getState().db;
const loadAccounts = useAuth.getState().loadAccounts;
if (!db) {
console.log("No database connection available.");
throw new Error("No database connection available.");
}
return new Error("Unexpected error happened");
try {
const response = await refreshTokenApi(refreshToken);
await updateAccountTokens(db, {
accountId: accountId,
access: response.access,
refresh: response.refresh,
});
processRefreshQueue(response.access);
return { access: response.access, refresh: response.refresh };
} catch (err) {
console.error("Token refresh failed:", err);
processRefreshQueue(null);
throw err;
} finally {
localStorage.removeItem("refreshing");
loadAccounts?.();
window.guard.refreshing = false;
}
};
axios.interceptors.request.use(
async (request) => {
const account = useAuth.getState().activeAccount;
let token: string | null = account?.access ?? null;
if (!token || !isExpired(token)) {
request.headers["Authorization"] = `Bearer ${token}`;
return request;
}
if (!window.guard.refreshing) {
console.log(`request to ${request.url} is refreshing token`);
window.guard.refreshing = true;
try {
const { access } = await refreshToken(
account!.accountId,
account!.refresh,
);
token = access;
} catch (err) {
console.error("Token refresh failed:", err);
await logout(account!.accountId);
throw err;
}
} else {
console.log(`request to ${request.url} is waiting for token`);
token = await waitForTokenRefresh();
console.log(`request to ${request.url} waited for token:`, token);
}
if (!token) {
// logout should be triggered by main process (refreshToken)
// await logout(account!.accountId);
throw new Error("No token available");
}
request.headers["Authorization"] = `Bearer ${token}`;
return request;
},
(error) => Promise.reject(error),
);
export const handleApiError = async (response: AxiosResponse) => {
const text =
response.data?.error ||
response.data?.toString?.() ||
"unexpected error happened";
return new Error(text[0].toUpperCase() + text.slice(1));
};

View File

@ -1,3 +1,4 @@
import axios from "axios";
import { handleApiError } from ".";
export interface LoginRequest {
@ -15,21 +16,15 @@ export interface LoginResponse {
}
export const loginApi = async (req: LoginRequest) => {
const response = await fetch("/api/v1/login", {
method: "POST",
body: JSON.stringify({
email: req.email,
password: req.password,
}),
headers: {
"Content-Type": "application/json",
},
const response = await axios.post("/api/v1/auth/login", {
email: req.email,
password: req.password,
});
if (response.status !== 200 && response.status !== 201)
throw await handleApiError(response);
const data: LoginResponse = await response.json();
const data: LoginResponse = response.data;
return data;
};

15
web/src/api/profile.ts Normal file
View File

@ -0,0 +1,15 @@
import type { UserProfile } from "@/types";
import { axios, handleApiError } from ".";
export type FetchProfileResponse = UserProfile;
export const fetchProfileApi = async () => {
const response = await axios.get("/api/v1/auth/profile");
if (response.status !== 200 && response.status !== 201)
throw await handleApiError(response);
const data: FetchProfileResponse = response.data;
return data;
};

27
web/src/api/refresh.ts Normal file
View File

@ -0,0 +1,27 @@
import axios from "axios";
import { handleApiError } from ".";
export interface RefreshTokenResponse {
access: string;
refresh: string;
}
export const refreshTokenApi = async (refreshToken: string) => {
const response = await axios.post(
"/api/v1/auth/refresh",
{},
{
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${refreshToken}`,
},
},
);
if (response.status !== 200 && response.status !== 201)
throw await handleApiError(response);
const data: RefreshTokenResponse = response.data;
return data;
};

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

@ -0,0 +1,34 @@
import { axios, handleApiError } from ".";
export const requestEmailOtpApi = async (): Promise<void> => {
const response = await axios.post("/api/v1/auth/email");
if (response.status !== 200 && response.status !== 201)
throw await handleApiError(response);
return response.data;
};
export interface ConfirmEmailRequest {
otp: string;
}
export const confirmEmailApi = async (
req: ConfirmEmailRequest,
): Promise<void> => {
const response = await axios.post("/api/v1/auth/email/otp", req);
if (response.status !== 200 && response.status !== 201)
throw await handleApiError(response);
return response.data;
};
export const finishVerificationApi = async (): Promise<void> => {
const response = await axios.post("/api/v1/auth/verify");
if (response.status !== 200 && response.status !== 201)
throw await handleApiError(response);
return response.data;
};

View File

@ -0,0 +1,25 @@
import type { FC } from "react";
import { useBarItems } from "@/hooks/barItems";
import { Link } from "react-router";
const Sidebar: FC = () => {
const [barItems, isActive] = useBarItems();
return (
<div className="hidden sm:flex flex-col gap-2 items-stretch border-r border-gray-300 dark:border-gray-700 min-w-80 w-80 p-5 pt-18 min-h-screen select-none">
{barItems.map((item) => (
<Link to={item.pathname} key={item.tab}>
<div
className={`dark:text-gray-200 transition-colors text-sm cursor-pointer p-4 rounded-lg flex flex-row items-center gap-3${
isActive(item) ? " bg-gray-200 dark:bg-gray-900" : ""
}`}
>
{item.icon} {item.title}
</div>
</Link>
))}
</div>
);
};
export default Sidebar;

View File

@ -0,0 +1,27 @@
import { type FC } from "react";
import { useBarItems } from "@/hooks/barItems";
import { Link } from "react-router";
const TopBar: FC = () => {
const [barItems, isActive] = useBarItems();
return (
<div className="sm:hidden flex w-full overflow-x-auto sm:overflow-x-visible max-w-full min-w-full sm:justify-center sm:space-x-4 no-scrollbar shadow-md shadow-gray-300 dark:shadow-gray-700 dark:bg-black/70 bg-white/70">
{barItems.map((item) => (
<Link to={item.pathname} key={item.tab}>
<div
className={`flex-shrink-0 transition-all border-b-4 px-4 py-2 min-w-[120px] sm:min-w-0 sm:flex-1 flex items-center justify-center cursor-pointer select-none whitespace-nowrap text-sm font-medium ${
isActive(item)
? " border-b-4 border-b-blue-500 text-blue-500"
: " border-b-transparent text-gray-500"
}`}
>
{item.title}
</div>
</Link>
))}
</div>
);
};
export default TopBar;

View File

@ -0,0 +1,35 @@
import type { FC } from "react";
import { Link } from "react-router";
interface BreadItem {
href?: string;
label: string;
}
interface IBreadcrumbsProps {
items: BreadItem[];
className?: string;
}
const Breadcrumbs: FC<IBreadcrumbsProps> = ({ items, className }) => {
return (
<div
className={`${className ? `${className} ` : ""} flex flex-row p-1 sm:p-3 gap-3 items-center text-gray-800 dark:text-gray-200 text-sm sm:text-base`}
>
{items.map((item, index) => (
<>
{item.href ? (
<Link to={item.href}>
<p className="text-blue-500">{item.label}</p>
</Link>
) : (
<p>{item.label}</p>
)}
{index + 1 < items.length && <p className="text-gray-500">/</p>}
</>
))}
</div>
);
};
export default Breadcrumbs;

View File

@ -9,7 +9,7 @@ interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
children: ReactNode;
className?: string;
loading?: boolean;
variant?: "contained" | "outlined" | "text";
variant?: "contained" | "outlined" | "text" | "icon";
}
export const Button: FC<ButtonProps> = ({
@ -22,11 +22,13 @@ export const Button: FC<ButtonProps> = ({
const appearance = useMemo(() => {
switch (variant) {
case "contained":
return "bg-blue-600 text-white hover:bg-blue-700";
return "px-4 py-2 bg-blue-600 text-white hover:bg-blue-700";
case "outlined":
return "border border-blue-600 text-blue-600 hover:text-blue-700 font-medium";
return "px-4 py-2 border border-blue-600 text-blue-600 hover:text-blue-700 font-medium";
case "text":
return "text-blue-600 hover:text-blue-700 font-medium";
return "py-2 px-4 text-blue-600 hover:text-blue-700 font-medium";
case "icon":
return "py-0 px-0 text-gray-400 hover:text-gray-600";
}
return "";
@ -34,7 +36,7 @@ export const Button: FC<ButtonProps> = ({
return (
<button
className={`cursor-pointer py-2 px-4 rounded-md transition-colors ${appearance} ${
className={`${appearance} cursor-pointer rounded-md transition-colors ${
className || ""
}${
loading

View File

@ -3,16 +3,29 @@ import type { FC, ReactNode } from "react";
interface ComponentProps {
children: ReactNode;
className?: string;
spacing?: boolean;
}
export const Card: FC<ComponentProps> = ({ children, className }) => {
return (
<div className={`bg-white sm:rounded-lg shadow-md ${className || ""}`}>
<div
className={`bg-white sm:rounded-lg overflow-hidden shadow-md ${
className || ""
}`}
>
{children}
</div>
);
};
export function CardContent({ children, className }: ComponentProps) {
return <div className={`p-4 ${className || ""}`}>{children}</div>;
export function CardContent({
children,
className,
spacing = true,
}: ComponentProps) {
return (
<div className={`${spacing ? "p-4 " : ""}${className || ""}`}>
{children}
</div>
);
}

View File

@ -6,7 +6,7 @@ export const Input: FC<InputProps> = ({ className, ...props }) => {
return (
<input
{...props}
className={`w-full border border-gray-300 dark:border-gray-600 dark:placeholder-gray-600 dark:text-gray-100 rounded-md px-3 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 ${
className={`w-full border border-gray-300 dark:border-gray-700 dark:placeholder-gray-600 dark:text-gray-100 rounded-md px-3 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 ${
className || ""
}`}
/>

View File

@ -0,0 +1,91 @@
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

@ -1,16 +0,0 @@
import type { IDBPDatabase } from "idb";
import { createContext, useContext } from "react";
interface DbContextValues {
db: IDBPDatabase | null;
connected: boolean;
setDb: (db: IDBPDatabase) => void;
}
export const DbContext = createContext<DbContextValues>({
db: null,
connected: false,
setDb: () => {},
});
export const useDbContext = () => useContext(DbContext);

View File

@ -1,25 +0,0 @@
import { useCallback, useState, type FC, type ReactNode } from "react";
import { DbContext } from "./db";
import type { IDBPDatabase } from "idb";
interface IDBProvider {
children: ReactNode;
}
export const DbProvider: FC<IDBProvider> = ({ children }) => {
const [db, _setDb] = useState<IDBPDatabase | null>(null);
const setDb = useCallback((db: IDBPDatabase) => _setDb(db), []);
return (
<DbContext.Provider
value={{
db,
connected: Boolean(db),
setDb,
}}
>
{children}
</DbContext.Provider>
);
};

View File

@ -1,65 +1,34 @@
import { useDbContext } from "@/context/db/db";
import { type LocalAccount, useAccountRepo } from "@/repository/account";
import { CirclePlus, User } from "lucide-react";
import { useEffect, useState, type FC } from "react";
import { type LocalAccount } from "@/repository/account";
import { useAuth } from "@/store/auth";
import { CirclePlus } from "lucide-react";
import { useCallback, type FC } from "react";
import { Link, useLocation } from "react-router";
import Avatar from "../Avatar";
const AccountList: FC = () => {
const [accounts, setAccounts] = useState<LocalAccount[]>([]);
const accounts = useAuth((state) => state.accounts);
const updateActiveAccount = useAuth((state) => state.updateActiveAccount);
const repo = useAccountRepo();
const { connected } = useDbContext();
const location = useLocation();
useEffect(() => {
if (connected) repo.loadAll().then(setAccounts);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [connected]);
if (!connected) {
return (
<div className="p-5 flex-1 h-full flex-full flex items-center justify-center">
<div role="status">
<svg
aria-hidden="true"
className="w-12 h-12 text-gray-200 dark:text-gray-600 animate-spin fill-blue-600"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
<span className="sr-only">Loading...</span>
</div>
</div>
);
}
const handleAccountSelect = useCallback(
(account: LocalAccount) => {
updateActiveAccount(account);
},
[updateActiveAccount],
);
return (
<>
{accounts.map((account) => (
<div
key={account.accountId}
onClick={() => handleAccountSelect(account)}
className="flex flex-row items-center p-4 border-gray-200 dark:border-gray-700/65 border-b border-r-0 border-l-0 select-none cursor-pointer hover:bg-gray-50/50 dark:hover:bg-gray-800/10 transition-colors mb-0"
>
<div>
<div className="rounded-full w-10 h-10 overflow-hidden bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-gray-200 mr-3 ring ring-gray-400 dark:ring dark:ring-gray-500">
{account.profilePicture ? (
<img
src={account.profilePicture}
className="w-full h-full flex-1"
alt="profile"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<User />
</div>
)}
<Avatar iconSize={8} avatarId={account.profilePicture ?? null} />
</div>
</div>
<div className="flex flex-col">
@ -72,16 +41,18 @@ const AccountList: FC = () => {
</div>
</div>
))}
<div className="flex flex-row items-center p-4 border-gray-200 dark:border-gray-700/65 border-b border-r-0 border-l-0 select-none cursor-pointer hover:bg-gray-50/50 dark:hover:bg-gray-800/10 transition-colors mb-0">
<div>
<div className="rounded-full p-2 text-gray-900 dark:text-gray-200 mr-3">
<CirclePlus />
<Link to="/auth/login" state={location.state}>
<div className="flex flex-row items-center p-4 border-gray-200 dark:border-gray-700/65 border-b border-r-0 border-l-0 select-none cursor-pointer hover:bg-gray-50/50 dark:hover:bg-gray-800/10 transition-colors mb-0">
<div>
<div className="rounded-full p-2 text-gray-900 dark:text-gray-200 mr-3">
<CirclePlus />
</div>
</div>
<p className="text-base text-gray-900 dark:text-gray-200">
Add new account
</p>
</div>
<p className="text-base text-gray-900 dark:text-gray-200">
Add new account
</p>
</div>
</Link>
</>
);
};

View File

@ -0,0 +1,65 @@
import { createPortal } from "react-dom";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { X } from "lucide-react";
import { useApiServices } from "@/store/admin/apiServices";
import type { ApiServiceCredentials } from "@/types";
const download = (credentials: ApiServiceCredentials) => {
const jsonStr = JSON.stringify(credentials, null, 2);
const blob = new Blob([jsonStr], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `credentials.json`;
a.click();
URL.revokeObjectURL(url);
};
const ApiServiceCredentialsModal = () => {
const credentials = useApiServices((state) => state.createdCredentials);
const resetCredentials = useApiServices((state) => state.resetCredentials);
const portalRoot = document.getElementById("portal-root");
if (!portalRoot || !credentials) return null;
return createPortal(
<div className="fixed z-50 inset-0 flex items-center justify-center bg-black/30 dark:bg-white/30 px-5">
<div className="rounded-2xl flex flex-col items-stretch bg-white dark:bg-black min-w-[300px] max-w-md w-full">
<div className="flex flex-row items-center justify-between p-4 border-b dark:border-gray-800 border-gray-300">
<p className="text-gray-800 dark:text-gray-200">New Credentials</p>
<Button variant="icon" onClick={resetCredentials}>
<X />
</Button>
</div>
<div className="p-4">
<div className="flex flex-col gap-2 mb-4">
<p className="text-gray-600 dark:text-gray-400 text-sm">
Client ID
</p>
<Input value={credentials.client_id} disabled />
</div>
<div className="flex flex-col gap-2 mb-4">
<p className="text-gray-600 dark:text-gray-400 text-sm">
Client Secret
</p>
<Input value={credentials.client_secret} disabled />
</div>
<div className="mt-4 flex flex-row items-center justify-between">
<Button variant="contained" onClick={resetCredentials}>
Close
</Button>
<Button variant="outlined" onClick={() => download(credentials)}>
Download
</Button>
</div>
</div>
</div>
</div>,
portalRoot,
);
};
export default ApiServiceCredentialsModal;

View File

@ -0,0 +1,44 @@
import { createPortal } from "react-dom";
import { Button } from "@/components/ui/button";
import { CircleCheckBig, X } from "lucide-react";
import { useApiServices } from "@/store/admin/apiServices";
const ApiServiceUpdatedModal = () => {
const resetUpdated = useApiServices((state) => state.resetUpdated);
const portalRoot = document.getElementById("portal-root");
if (!portalRoot) return null;
return createPortal(
<div className="fixed z-50 inset-0 flex items-center justify-center bg-black/30 dark:bg-white/30 px-5">
<div className="rounded-2xl flex flex-col items-stretch bg-white dark:bg-black min-w-[300px] max-w-md w-full">
<div className="flex flex-row items-center justify-between p-4 border-b dark:border-gray-800 border-gray-300">
<p className="text-gray-800 dark:text-gray-200">Service Updated</p>
<Button variant="icon" onClick={resetUpdated}>
<X />
</Button>
</div>
<div className="p-4">
<div className="mb-4 flex flex-col items-center p-4 text-green-400 gap-3">
<CircleCheckBig size={64} />
<h2 className="text-gray-800 dark:text-gray-200 text-xl">
Service has updated successfully!
</h2>
</div>
<div className="mt-4 w-full">
<Button
variant="outlined"
className="w-full"
onClick={resetUpdated}
>
Close
</Button>
</div>
</div>
</div>
</div>,
portalRoot,
);
};
export default ApiServiceUpdatedModal;

View File

@ -0,0 +1,36 @@
import { useAuth } from "@/store/auth";
import { User } from "lucide-react";
import { useMemo, type FC } from "react";
export interface AvatarProps {
iconSize?: number;
className?: string;
avatarId?: string;
}
const Avatar: FC<AvatarProps> = ({ iconSize = 32, className, avatarId }) => {
const profile = useAuth((state) => state.profile);
const avatar = useMemo(
() => (avatarId !== undefined ? avatarId : profile?.profile_picture),
[avatarId, profile?.profile_picture],
);
return (
<div
className={`overflow-hidden bg-gray-100 rounded-full ring ring-gray-400 dark:ring dark:ring-gray-500 flex items-center justify-center ${className}`}
>
{avatar ? (
<img
src={avatar}
className="w-full h-full flex-1 object-cover"
alt="profile"
/>
) : (
<User size={iconSize} className="text-gray-800" />
)}
</div>
);
};
export default Avatar;

View File

@ -0,0 +1,68 @@
import { useAuth } from "@/store/auth";
import { Blocks, Home, Settings2, User, Users } from "lucide-react";
import { useCallback, type ReactNode } from "react";
import { useLocation } from "react-router";
export interface BarItem {
icon: ReactNode;
title: string;
tab: string;
pathname: string;
}
export const useBarItems = (): [BarItem[], (item: BarItem) => boolean] => {
const profile = useAuth((state) => state.profile);
const location = useLocation();
const isActive = useCallback(
(item: BarItem) => {
if (item.pathname === "/") return location.pathname === item.pathname;
return location.pathname.startsWith(item.pathname);
},
[location.pathname],
);
if (!profile) {
return [[], isActive];
}
return [
[
{
icon: <Home />,
title: "Home",
tab: "home",
pathname: "/",
},
{
icon: <User />,
title: "Personal Info",
tab: "personal-info",
pathname: "/personal-info",
},
{
icon: <Settings2 />,
title: "Data & Personalization",
tab: "data-personalization",
pathname: "/data-personalize",
},
...(profile.is_admin
? [
{
icon: <Blocks />,
title: "API Services",
tab: "admin.api-services",
pathname: "/admin/api-services",
},
{
icon: <Users />,
title: "Users",
tab: "admin.users",
pathname: "/admin/users",
},
]
: []),
],
isActive,
];
};

View File

@ -1,6 +1,16 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap");
@import "tailwindcss";
@layer utilities {
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
}
html,
body {
font-family: "Inter", Arial, Helvetica, sans-serif;

View File

@ -0,0 +1,42 @@
import { useAuth } from "@/store/auth";
import type { FC } from "react";
import { Navigate, Outlet } from "react-router";
const AdminLayout: FC = () => {
const profile = useAuth((state) => state.profile);
if (!profile) {
<div className="w-screen h-screen flex flex-1 items-center justify-center flex-col">
<div role="status">
<svg
aria-hidden="true"
className="w-10 h-10 text-gray-400 animate-spin fill-white"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
<span className="sr-only">Loading...</span>
</div>
<p className="text-gray-200 dark:text-gray-400 mt-4 text-lg">
Loading...
</p>
</div>;
}
if (!profile?.is_admin) {
return <Navigate to="/not-allowed" />;
}
return <Outlet />;
};
export default AdminLayout;

View File

@ -0,0 +1,168 @@
import { useDbStore } from "@/store/db";
import { useAuth } from "@/store/auth";
import { useEffect, useMemo } from "react";
import {
Navigate,
Outlet,
useLocation,
useNavigate,
useSearchParams,
} from "react-router";
import BackgroundLayout from "./BackgroundLayout";
import { useOAuth } from "@/store/oauth";
const AuthLayout = () => {
const { connecting, db, connect } = useDbStore();
const [searchParams] = useSearchParams();
const parseSearchParams = useOAuth((state) => state.parseSearchParams);
const dbConnected = useMemo(() => !!db, [db]);
const loadingAccounts = useAuth((state) => state.loadingAccounts);
const loadAccounts = useAuth((state) => state.loadAccounts);
const hasLoadedAccounts = useAuth((state) => state.hasLoadedAccounts);
const activeAccount = useAuth((state) => state.activeAccount);
const hasAccounts = useAuth((state) => state.accounts.length > 0);
const authenticating = useAuth((state) => state.authenticating);
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();
const navigate = useNavigate();
const isAuthPage = useMemo(() => {
const pathname = location.pathname.replace(/\/$/i, "");
return pathname !== "/auth" && pathname.startsWith("/auth");
}, [location.pathname]);
const loading = useMemo(() => {
if (isAuthPage) {
return connecting;
}
return (
!hasAuthenticated ||
!hasLoadedAccounts ||
loadingAccounts ||
authenticating ||
connecting
);
}, [
isAuthPage,
hasAuthenticated,
hasLoadedAccounts,
loadingAccounts,
authenticating,
connecting,
]);
const verificationRequired = useMemo(() => {
return (
authProfile?.email_verified === false ||
authProfile?.avatar_verified === false ||
authProfile?.verified === false
);
}, [
authProfile?.avatar_verified,
authProfile?.email_verified,
authProfile?.verified,
]);
// OAuth
useEffect(() => {
console.log(
"parsing url search params:",
Object.fromEntries(searchParams.entries()),
);
parseSearchParams(searchParams);
}, [parseSearchParams, searchParams]);
// Database
useEffect(() => {
connect();
}, [connect]);
// Account Manager
useEffect(() => {
if (dbConnected) {
loadAccounts();
}
}, [dbConnected, loadAccounts]);
// Fetch Profile
useEffect(() => {
if (dbConnected && !loadingAccounts && activeAccount) {
authenticate();
}
}, [activeAccount, dbConnected, authenticate, loadingAccounts]);
useEffect(() => {
if (!signInRequired && isAuthPage) {
const to = location.state?.from ?? "/";
navigate(to, { state: { reset: true } });
}
}, [isAuthPage, location.state?.from, navigate, signInRequired]);
if (signInRequired && !isAuthPage) {
return (
<Navigate
to={hasAccounts ? "/auth/authenticate" : "/auth/login"}
state={{ from: location.pathname }}
/>
);
}
if (loading) {
return (
<BackgroundLayout>
<div className="w-screen h-screen flex flex-1 items-center justify-center flex-col">
<div role="status">
<svg
aria-hidden="true"
className="w-10 h-10 text-gray-400 animate-spin fill-white"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
<span className="sr-only">Loading...</span>
</div>
<p className="text-gray-200 dark:text-gray-400 mt-4 text-lg">
Loading...
</p>
</div>
</BackgroundLayout>
);
}
if (
!signInRequired &&
verificationRequired &&
!location.pathname.startsWith("/verify")
) {
return <Navigate to="/verify" state={{ from: location.pathname }} />;
}
return (
<BackgroundLayout>
<Outlet />
</BackgroundLayout>
);
};
export default AuthLayout;

View File

@ -0,0 +1,17 @@
import { type FC, type ReactNode } from "react";
export interface IBackgroundLayoutProps {
children: ReactNode;
}
const BackgroundLayout: FC<IBackgroundLayoutProps> = ({ children }) => {
return (
// <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>
);
};
export default BackgroundLayout;

View File

@ -0,0 +1,42 @@
import Sidebar from "@/components/Home/Sidebar";
import TopBar from "@/components/Home/TopBar";
import { Card, CardContent } from "@/components/ui/card";
import { type FC } from "react";
import { Outlet } from "react-router";
const DashboardLayout: FC = () => {
return (
<div className="relative z-10 flex items-center justify-center min-h-screen">
<Card className="min-h-screen w-full min-w-full h-screen max-h-screen shadow-lg bg-white/85 dark:bg-black/85 backdrop-blur-md sm:rounded-none overflow-y-auto sm:overflow-hidden">
<div className="flex flex-col w-full h-full flex-1 items-center sm:pt-0">
<div className="flex w-full sm:w-auto p-4 sm:p-0 flex-row items-center sm:absolute sm:left-4 sm:top-4">
<img src="/icon.png" alt="icon" className="w-6 h-6" />
<div className="ml-2">
<p className="text-sm text-gray-600 text-left dark:text-gray-500">
Home Guard
</p>
</div>
</div>
<CardContent
className="w-full space-y-4 flex-1 bg-black/5 dark:bg-white/5"
spacing={false}
>
<div className="flex flex-row">
<Sidebar />
<div className="max-w-full flex-1 sm:overflow-y-auto sm:max-h-screen">
<div className="flex flex-col w-full items-center gap-2">
<TopBar />
</div>
<Outlet />
</div>
</div>
</CardContent>
</div>
</Card>
</div>
);
};
export default DashboardLayout;

View File

@ -0,0 +1,86 @@
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 { Navigate, Outlet, useLocation, useNavigate } 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);
const redirect = useVerify((s) => s.redirect);
const setRedirect = useVerify((s) => s.setRedirect);
const navigate = useNavigate();
useEffect(() => {
if (profile) loadStep(profile);
}, [loadStep, profile]);
useEffect(() => {
if (location.state?.from) {
setRedirect(location.state.from);
}
}, [location.state?.from, setRedirect]);
useEffect(() => {
if (step === false) {
navigate(redirect ?? "/", { state: { reset: true } });
}
}, [navigate, redirect, step]);
if (
step === "email" &&
!location.pathname.startsWith("/verify/email") &&
location.pathname.replace(/\/$/i, "") !== "/verify"
) {
return <Navigate to="/verify/email" />;
}
if (step === "avatar" && !location.pathname.startsWith("/verify/avatar")) {
return <Navigate to="/verify/avatar" />;
}
if (step === "review" && !location.pathname.startsWith("/verify/review")) {
return <Navigate to="/verify/review" />;
}
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" &&
typeof step === "string" && (
<Stepper steps={steps} currentStep={step} />
)}
<Outlet />
</div>
</div>
);
};
export default VerificationLayout;

View File

@ -2,12 +2,14 @@ import { createRoot } from "react-dom/client";
import App from "./App";
import "./index.css";
import { DbProvider } from "./context/db/provider";
if (typeof window.guard !== "object") {
window.guard = {
refreshing: false,
refreshQueue: [],
};
}
const root = document.getElementById("root")!;
createRoot(root).render(
<DbProvider>
<App />
</DbProvider>
);
createRoot(root).render(<App />);

View File

@ -0,0 +1,192 @@
import Breadcrumbs from "@/components/ui/breadcrumbs";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import ApiServiceCredentialsModal from "@/feature/ApiServiceCredentialsModal";
import { useApiServices } from "@/store/admin/apiServices";
import { useCallback, type FC } from "react";
import { useForm } from "react-hook-form";
import { Link } from "react-router";
interface FormData {
name: string;
description: string;
redirectUris: string;
scopes: string;
grantTypes: string;
enabled: boolean;
}
const ApiServiceCreatePage: FC = () => {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormData>({
defaultValues: {
enabled: true,
scopes: "openid",
},
});
const createApiService = useApiServices((state) => state.create);
const credentials = useApiServices((state) => state.createdCredentials);
const onSubmit = useCallback(
(data: FormData) => {
console.log("Form submitted:", data);
createApiService({
name: data.name,
description: data.description ?? "",
redirect_uris: data.redirectUris.trim().split("\n"),
scopes: data.scopes.trim().split(" "),
grant_types: data.grantTypes
? data.grantTypes.trim().split(" ")
: ["authorization_code"],
is_active: data.enabled,
});
},
[createApiService],
);
return (
<div className="p-4">
{credentials !== null && <ApiServiceCredentialsModal />}
<Breadcrumbs
items={[
{ href: "/admin", label: "Admin" },
{ href: "/admin/api-services", label: "API Services" },
{ label: "Create new API Service" },
]}
/>
<form onSubmit={handleSubmit(onSubmit)}>
{/* Service Information */}
<div className="border dark:border-gray-800 border-gray-300 rounded mt-4 flex flex-col">
<div className="p-4 border-b dark:border-gray-800 border-gray-300">
<h2 className="text-gray-800 dark:text-gray-200">
Service Information
</h2>
</div>
<div className="p-4">
<div className="flex flex-col gap-2 mb-4">
<p className="text-gray-600 dark:text-gray-400 text-sm">Name</p>
<Input
placeholder="Display Name"
{...register("name", { required: "Name is required" })}
/>
{errors.name && (
<span className="text-red-500 text-sm">
{errors.name.message}
</span>
)}
</div>
<div className="flex flex-col gap-2 mb-4">
<p className="text-gray-600 dark:text-gray-400 text-sm">
Description
</p>
<textarea
{...register("description", {
required: "Description is required",
})}
className="dark:text-gray-100 border border-gray-300 dark:border-gray-700 rounded placeholder:text-gray-600 text-sm p-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Some description goes here..."
></textarea>
{errors.description && (
<span className="text-red-500 text-sm">
{errors.description.message}
</span>
)}
</div>
</div>
</div>
{/* OpenID Connect */}
<div className="border dark:border-gray-800 border-gray-300 rounded mt-4 flex flex-col">
<div className="p-4 border-b dark:border-gray-800 border-gray-300">
<h2 className="text-gray-800 dark:text-gray-200">OpenID Connect</h2>
</div>
<div className="p-4">
<div className="flex flex-col gap-2 mb-4">
<p className="text-gray-600 dark:text-gray-400 text-sm">
Redirect URIs
</p>
<textarea
{...register("redirectUris", {
required: "At least one URI is required",
})}
className="dark:text-gray-100 border border-gray-300 dark:border-gray-700 rounded placeholder:text-gray-600 text-sm p-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Enter multiple URIs separated with new line"
></textarea>
{errors.redirectUris && (
<span className="text-red-500 text-sm">
{errors.redirectUris.message}
</span>
)}
</div>
<div className="flex flex-col gap-2 mb-4">
<p className="text-gray-600 dark:text-gray-400 text-sm">Scopes</p>
<Input
placeholder="Scopes separated with space"
{...register("scopes", { required: "Scopes are required" })}
/>
{errors.scopes && (
<span className="text-red-500 text-sm">
{errors.scopes.message}
</span>
)}
</div>
<div className="flex flex-col gap-2 mb-4">
<p className="text-gray-600 dark:text-gray-400 text-sm">
Grant Types
</p>
<Input
placeholder="Leave empty for 'authorization_code'"
{...register("grantTypes")}
/>
</div>
</div>
</div>
{/* Final Section */}
<div className="border dark:border-gray-800 border-gray-300 rounded mt-4 flex flex-col">
<div className="p-4 border-b dark:border-gray-800 border-gray-300">
<h2 className="text-gray-800 dark:text-gray-200">
Final Customization & Submit
</h2>
</div>
<div className="p-4">
<label className="inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
{...register("enabled")}
/>
<div className="relative w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600 dark:peer-checked:bg-blue-600"></div>
<span className="ms-3 text-sm font-medium text-gray-900 dark:text-gray-300">
Enabled by default
</span>
</label>
<div className="flex flex-row items-center justify-between gap-2 mt-4">
<Button type="submit">Create</Button>
<Link to="/admin/api-services">
<Button
variant="text"
className="text-red-400 hover:text-red-500"
>
Cancel
</Button>
</Link>
</div>
</div>
</div>
</form>
</div>
);
};
export default ApiServiceCreatePage;

View File

@ -0,0 +1,199 @@
import Breadcrumbs from "@/components/ui/breadcrumbs";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import ApiServiceUpdatedModal from "@/feature/ApiServiceUpdatedModal";
import { useApiServices } from "@/store/admin/apiServices";
import { useCallback, useEffect, type FC } from "react";
import { useForm } from "react-hook-form";
import { Link, useParams } from "react-router";
interface FormData {
name: string;
description: string;
redirectUris: string;
scopes: string;
grantTypes: string;
}
const ApiServiceEditPage: FC = () => {
const {
register,
handleSubmit,
formState: { errors },
setValue,
} = useForm<FormData>({
defaultValues: {
scopes: "openid",
},
});
const { serviceId } = useParams();
const apiService = useApiServices((state) => state.view);
const loadService = useApiServices((state) => state.fetchSingle);
const updateApiService = useApiServices((state) => state.update);
const updating = useApiServices((state) => state.updating);
const updated = useApiServices((state) => state.updated);
const onSubmit = useCallback(
(data: FormData) => {
console.log("Form submitted:", data);
updateApiService({
name: data.name,
description: data.description ?? "",
redirect_uris: data.redirectUris.trim().split("\n"),
scopes: data.scopes.trim().split(" "),
grant_types: data.grantTypes
? data.grantTypes.trim().split(" ")
: ["authorization_code"],
});
},
[updateApiService],
);
useEffect(() => {
if (typeof serviceId === "string") loadService(serviceId);
}, [loadService, serviceId]);
useEffect(() => {
if (apiService != null) {
setValue("name", apiService.name);
setValue("description", apiService.description);
setValue("redirectUris", apiService.redirect_uris.join("\n"));
setValue("scopes", apiService.scopes.join(" "));
setValue("grantTypes", apiService.grant_types.join(" "));
}
}, [apiService, setValue]);
return (
<div className="p-4">
{updated && <ApiServiceUpdatedModal />}
<Breadcrumbs
items={[
{ href: "/admin", label: "Admin" },
{ href: "/admin/api-services", label: "API Services" },
{ label: "Create new API Service" },
]}
/>
<form onSubmit={handleSubmit(onSubmit)}>
{/* Service Information */}
<div className="border dark:border-gray-800 border-gray-300 rounded mt-4 flex flex-col">
<div className="p-4 border-b dark:border-gray-800 border-gray-300">
<h2 className="text-gray-800 dark:text-gray-200">
Service Information
</h2>
</div>
<div className="p-4">
<div className="flex flex-col gap-2 mb-4">
<p className="text-gray-600 dark:text-gray-400 text-sm">Name</p>
<Input
placeholder="Display Name"
{...register("name", { required: "Name is required" })}
/>
{errors.name && (
<span className="text-red-500 text-sm">
{errors.name.message}
</span>
)}
</div>
<div className="flex flex-col gap-2 mb-4">
<p className="text-gray-600 dark:text-gray-400 text-sm">
Description
</p>
<textarea
{...register("description", {
required: "Description is required",
})}
className="dark:text-gray-100 border border-gray-300 dark:border-gray-700 rounded placeholder:text-gray-600 text-sm p-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Some description goes here..."
></textarea>
{errors.description && (
<span className="text-red-500 text-sm">
{errors.description.message}
</span>
)}
</div>
</div>
</div>
{/* OpenID Connect */}
<div className="border dark:border-gray-800 border-gray-300 rounded mt-4 flex flex-col">
<div className="p-4 border-b dark:border-gray-800 border-gray-300">
<h2 className="text-gray-800 dark:text-gray-200">OpenID Connect</h2>
</div>
<div className="p-4">
<div className="flex flex-col gap-2 mb-4">
<p className="text-gray-600 dark:text-gray-400 text-sm">
Redirect URIs
</p>
<textarea
{...register("redirectUris", {
required: "At least one URI is required",
})}
className="dark:text-gray-100 border border-gray-300 dark:border-gray-700 rounded placeholder:text-gray-600 text-sm p-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Enter multiple URIs separated with new line"
></textarea>
{errors.redirectUris && (
<span className="text-red-500 text-sm">
{errors.redirectUris.message}
</span>
)}
</div>
<div className="flex flex-col gap-2 mb-4">
<p className="text-gray-600 dark:text-gray-400 text-sm">Scopes</p>
<Input
placeholder="Scopes separated with space"
{...register("scopes", { required: "Scopes are required" })}
/>
{errors.scopes && (
<span className="text-red-500 text-sm">
{errors.scopes.message}
</span>
)}
</div>
<div className="flex flex-col gap-2 mb-4">
<p className="text-gray-600 dark:text-gray-400 text-sm">
Grant Types
</p>
<Input
placeholder="Leave empty for 'authorization_code'"
{...register("grantTypes")}
/>
</div>
</div>
</div>
{/* Final Section */}
<div className="border dark:border-gray-800 border-gray-300 rounded mt-4 flex flex-col">
<div className="p-4 border-b dark:border-gray-800 border-gray-300">
<h2 className="text-gray-800 dark:text-gray-200">
Approve & Submit
</h2>
</div>
<div className="p-4">
<div className="flex flex-row items-center justify-between gap-2 mt-4">
<Button type="submit" loading={updating}>
Update
</Button>
<Link to="/admin/api-services">
<Button
variant="text"
className="text-red-400 hover:text-red-500"
>
Cancel
</Button>
</Link>
</div>
</div>
</div>
</form>
</div>
);
};
export default ApiServiceEditPage;

View File

@ -0,0 +1,205 @@
import Breadcrumbs from "@/components/ui/breadcrumbs";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useApiServices } from "@/store/admin/apiServices";
import { useEffect, type FC } from "react";
import { Link, useParams } from "react-router";
const InfoCard = ({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) => (
<div className="border dark:border-gray-800 border-gray-300 rounded mb-4">
<div className="p-4 border-b dark:border-gray-800 border-gray-300">
<h2 className="text-gray-800 dark:text-gray-200 font-semibold text-lg">
{title}
</h2>
</div>
<div className="p-4">{children}</div>
</div>
);
const ViewApiServicePage: FC = () => {
const { serviceId } = useParams();
const apiService = useApiServices((state) => state.view);
// const loading = useApiServices((state) => state.fetchingApiService);
const loadService = useApiServices((state) => state.fetchSingle);
const toggling = useApiServices((state) => state.toggling);
const toggle = useApiServices((state) => state.toggle);
useEffect(() => {
if (typeof serviceId === "string") loadService(serviceId);
}, [loadService, serviceId]);
if (!apiService) {
return (
<div className="p-4 flex items-center justify-center h-[60vh]">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-blue-500 border-t-transparent mx-auto mb-3" />
<p className="text-gray-600 dark:text-gray-400">
Loading API Service...
</p>
</div>
</div>
);
}
return (
<div className="dark:text-gray-200 text-gray-800 p-4">
<Breadcrumbs
items={[
{ href: "/admin", label: "Admin" },
{ href: "/admin/api-services", label: "API Services" },
{ label: "View API Service" },
]}
/>
<div className="sm:p-4 pt-4">
{/* 📋 Main Details */}
<InfoCard title="API Service Details">
<div className="flex flex-col gap-4 text-sm text-gray-700 dark:text-gray-300">
<div>
<span className="font-medium text-gray-900 dark:text-white">
Name:
</span>{" "}
{apiService.name}
</div>
<div>
<span className="font-medium text-gray-900 dark:text-white">
Description:
</span>{" "}
{apiService.description}
</div>
<div>
<span className="font-medium text-gray-900 dark:text-white">
Redirect URIs:
</span>
<ul className="list-disc list-inside ml-4 mt-1">
{apiService.redirect_uris.map((uri) => (
<li key={uri}>{uri}</li>
))}
</ul>
</div>
<div>
<span className="font-medium text-gray-900 dark:text-white">
Scopes:
</span>
<div className="flex flex-wrap gap-2 mt-1">
{apiService.scopes.map((scope) => (
<span
key={scope}
className="bg-blue-100 dark:bg-blue-800/30 text-blue-700 dark:text-blue-300 text-xs font-medium px-2 py-1 rounded"
>
{scope}
</span>
))}
</div>
</div>
<div>
<span className="font-medium text-gray-900 dark:text-white">
Grant Types:
</span>
<div className="flex flex-wrap gap-2 mt-1">
{apiService.grant_types.map((grant) => (
<span
key={grant}
className="bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-300 text-xs font-medium px-2 py-1 rounded"
>
{grant}
</span>
))}
</div>
</div>
<div>
<span className="font-medium text-gray-900 dark:text-white">
Created At:
</span>{" "}
{new Date(apiService.created_at).toLocaleString()}
</div>
<div>
<span className="font-medium text-gray-900 dark:text-white">
Updated At:
</span>{" "}
{new Date(apiService.updated_at).toLocaleString()}
</div>
<div>
<span className="font-medium text-gray-900 dark:text-white">
Status:
</span>{" "}
<span
className={`font-semibold px-2 py-1 rounded ${
apiService.is_active
? "bg-green-200 text-green-800 dark:bg-green-700/20 dark:text-green-300"
: "bg-red-200 text-red-800 dark:bg-red-700/20 dark:text-red-300"
}`}
>
{apiService.is_active ? "Active" : "Inactive"}
</span>
</div>
</div>
</InfoCard>
{/* 🔐 Credentials */}
<InfoCard title="Client Credentials">
<div className="flex flex-col gap-4">
<div>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-1">
<span className="font-medium text-gray-900 dark:text-white">
Client ID:
</span>
</p>
<Input value={apiService.client_id} disabled />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-1">
<span className="font-medium text-gray-900 dark:text-white">
Client Secret:
</span>
</p>
<Input value="***************" disabled />
</div>
<Button variant="outlined" onClick={() => {}}>
Regenerate Credentials
</Button>
</div>
</InfoCard>
{/* 🚀 Actions */}
<div className="flex flex-wrap gap-4 mt-6 justify-between items-center">
<Link to="/admin/api-services">
<Button variant="outlined">Back</Button>
</Link>
<div className="flex flex-row items-center gap-4">
<Button
variant="text"
className={
apiService.is_active
? "text-red-400 hover:text-red-500"
: "text-green-400 hover:text-green-500"
}
onClick={toggle}
loading={toggling}
>
{apiService.is_active ? "Disable" : "Enable"}
</Button>
<Link
to={`/admin/api-services/edit/${serviceId}`}
className="hover:underline hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
<Button variant="contained">Edit</Button>
</Link>
</div>
</div>
</div>
</div>
);
};
export default ViewApiServicePage;

Some files were not shown because too many files have changed in this diff Show More