Compare commits
168 Commits
e2ae03f2a6
...
group-role
Author | SHA1 | Date | |
---|---|---|---|
d35e5813b5 | |||
533e6ea6af | |||
0c24ed9382 | |||
f5c61bb6a0 | |||
eb05f830fe | |||
d86a9de388 | |||
d80caac81b | |||
5d49a661ed | |||
635a2d6058 | |||
1eb96e906d | |||
58974d9789 | |||
329fac415f | |||
a83ec61fae | |||
d5e86acacf | |||
221ef192bc | |||
18664dbd8b | |||
d097735965 | |||
992776b8a6 | |||
3f260b9029 | |||
65f40d0897 | |||
6d482c784f | |||
dc07868d15 | |||
09a2f05ee5 | |||
5cec1cf561 | |||
3281764eff | |||
868337134d | |||
9372673bf1 | |||
0eea81b42f | |||
7468303e41 | |||
8745e7d8bc | |||
bdc42beb27 | |||
72083fa0a4 | |||
b3ad13e55d | |||
0db54e0268 | |||
b3ef96a0ce | |||
a773f1f8b4 | |||
1a71f50914 | |||
d17e154e42 | |||
bad26775eb | |||
c3fd6637a5 | |||
20173ea140 | |||
41d439beab | |||
b36b6e18ca | |||
1765485027 | |||
c5fb5e674a | |||
03bf655051 | |||
f8589fce5d | |||
ac62158de9 | |||
44e1a18e9a | |||
e0f2c3219f | |||
d2d52dc041 | |||
d7d142152c | |||
5c321311cd | |||
ffc8a5f44d | |||
dbff94e7b3 | |||
0b1ef77689 | |||
b0005c6702 | |||
c2abf1a5ba | |||
ac50929e6e | |||
32785398ca | |||
cc497b6016 | |||
97ffcbdaa4 | |||
d09bf8ff02 | |||
c0814093e5 | |||
d48519741d | |||
213991126d | |||
c7e88606e3 | |||
a0d506fb76 | |||
0ec7743fca | |||
a8a0fa55b7 | |||
7321448ce7 | |||
6d5e0fc9a9 | |||
ef05d66787 | |||
b3296c45ad | |||
7fd163f957 | |||
0f0d50a684 | |||
68074e02bc | |||
8d38a86f86 | |||
e0c095c24d | |||
4c318b15cd | |||
5ea6bc4251 | |||
1cbe908489 | |||
53ee156e67 | |||
07a936acc7 | |||
f892f0da24 | |||
38955ee4e6 | |||
7fa7e87e88 | |||
f085f2e271 | |||
08add259a4 | |||
5b6142dfa6 | |||
dc41521a99 | |||
299e7eddc4 | |||
b4699e987c | |||
be9d4f2a1b | |||
db99236501 | |||
e33fb04c99 | |||
c3d4208e12 | |||
3f945fa329 | |||
93a5cd7c70 | |||
951de989af | |||
c5cf253a15 | |||
d70032e36d | |||
445ac50537 | |||
c13e564b01 | |||
5d3a77133d | |||
44592ebc08 | |||
1b941cb0c3 | |||
1cb520c2b6 | |||
b3fdd3bc18 | |||
9110db2f08 | |||
5972735102 | |||
14c69349cc | |||
3ceeab04e1 | |||
b7a67c208f | |||
cee885a84d | |||
4b496ea9bd | |||
570ae6ac8c | |||
f0d3a61e7b | |||
b09567620f | |||
2209846525 | |||
108ed61961 | |||
b73bfd590b | |||
19d56159ba | |||
b9ccf6adac | |||
9a0870dbbc | |||
70429f69a2 | |||
ad635008eb | |||
eacc8fdd89 | |||
d309fb3f57 | |||
f4fd993679 | |||
016879b53f | |||
70bba15cda | |||
57daf175ab | |||
0817a65272 | |||
13f9da1a67 | |||
83535acf1c | |||
441ce2daca | |||
f9848d2110 | |||
7f0511b0d4 | |||
a27f2ad593 | |||
715a984241 | |||
66e1756ade | |||
849403a137 | |||
8d15c9b8b2 | |||
87af1834cf | |||
357583f54d | |||
aa6de76ded | |||
ab3c2d1eb0 | |||
644cf2a358 | |||
5b1ed9925d | |||
4071a50a37 | |||
dd7c51efd8 | |||
8902f4d187 | |||
6666b20464 | |||
c5f288ba1e | |||
cc49ab1655 | |||
06c60b3491 | |||
b584a7b07f | |||
410e420a46 | |||
eeb0f6eac1 | |||
fb622f918a | |||
a50bad417f | |||
c395729446 | |||
eaa92d2fe4 | |||
a9e382d713 | |||
974244025e | |||
ae41076673 | |||
cc7f7f40c4 |
@ -5,6 +5,8 @@ GUARD_URI="http://localhost:3001"
|
||||
|
||||
GUARD_DB_URL="postgres://<user>:<user>@<host>:<port>/<db>?sslmode=disable"
|
||||
|
||||
GUARD_REDIS_URL="redis://guard:guard@localhost:6379/0"
|
||||
|
||||
GUARD_ADMIN_NAME="admin"
|
||||
GUARD_ADMIN_EMAIL="admin@test.net"
|
||||
GUARD_ADMIN_PASSWORD="secret"
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -27,6 +27,7 @@ go.work.sum
|
||||
|
||||
# env file
|
||||
.env
|
||||
.env.remote
|
||||
|
||||
# key files
|
||||
*.pem
|
||||
|
@ -26,6 +26,8 @@ RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/
|
||||
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
|
||||
|
||||
|
168
README.md
168
README.md
@ -1,123 +1,139 @@
|
||||
# HSP Guard
|
||||
|
||||
**HSP Guard** is an internal security service for your home lab, designed to manage user access to various home services and tools. It dynamically controls permissions and prevents unauthorized or unexpected users from accessing sensitive services.
|
||||
# 🛡️ HSP Guard
|
||||
|
||||
**HSP Guard** is a modern OpenID Connect (OIDC) identity provider and access management system for home labs. It provides secure authentication and granular authorization for all your self-hosted services, combining ease of use with enterprise-level control — without any vendor lock-in.
|
||||
|
||||
---
|
||||
|
||||
## 📌 Overview
|
||||
## ✨ Features
|
||||
|
||||
HSP Guard authorizes user requests and provides an efficient way to:
|
||||
|
||||
- Manage permissions for individual services/tools
|
||||
- Define roles for easier access control
|
||||
- Validate and authorize users via JWT tokens
|
||||
- Securely integrate with new services during installation
|
||||
- **OIDC Provider**: Central login for your home lab apps
|
||||
- **Admin UI**: Manage apps, users, roles, permissions, and sessions
|
||||
- **API Tokens**: Issue access tokens with embedded roles and permissions
|
||||
- **Flexible Authorization**: Support for roles, permissions, and groups (future)
|
||||
- **App Registration**: Register OAuth/OIDC clients with custom permissions
|
||||
- **Automatic Permission Sync**: Optionally fetch app permissions from `/.well-known/guard-configuration`
|
||||
- **User & Admin Sessions**: See and revoke active user/app sessions
|
||||
- **Pluggable**: Easily integrate new apps and services
|
||||
- **Audit Logging**: Track actions for security and troubleshooting (planned)
|
||||
|
||||
---
|
||||
|
||||
## 📚 Concepts
|
||||
## 🚀 Getting Started
|
||||
|
||||
### 🔐 Permission
|
||||
### 1. **Run HSP Guard**
|
||||
|
||||
Permissions define access to specific features or tools.
|
||||
By default, HSP Guard includes predefined administrative permissions that allow an admin to log in and configure the system.
|
||||
You can run HSP Guard via Docker, Docker Compose, or natively (see below).
|
||||
|
||||
Once logged in, the admin can:
|
||||
### 2. **Register Your First App**
|
||||
|
||||
- Manually create new permissions for specific applications
|
||||
- Allow new applications to register their own permissions
|
||||
- Assign permissions to users, granting them access to corresponding tools
|
||||
1. **Login as admin**
|
||||
2. Go to **Apps → Register New App**
|
||||
3. Enter:
|
||||
- **Name** of your app
|
||||
- **Redirect URIs** (for OIDC/OAuth callbacks)
|
||||
- (Optional) **Permissions** (manual or auto-discovered from the app)
|
||||
4. Save to receive a `client_id` and `client_secret`
|
||||
5. Configure your app to use these for OIDC login
|
||||
|
||||
### 3. **Assign Permissions & Roles**
|
||||
|
||||
- Assign **default roles** to new users automatically (configurable)
|
||||
- Create custom **roles** to bundle permissions (e.g., `FAMILY_MEMBER`)
|
||||
- Assign users to roles and/or groups for flexible access control
|
||||
|
||||
---
|
||||
|
||||
### 🧩 Role
|
||||
## 🏗️ Concepts
|
||||
|
||||
A **Role** is a named collection of permissions (e.g., `GUEST`, `FRIEND`, `FAMILY_MEMBER`) created by the admin.
|
||||
Roles simplify user management by allowing bulk assignment of permissions. Instead of assigning multiple permissions individually, a role bundles them under one label.
|
||||
### 🔑 **Permissions**
|
||||
Fine-grained controls for app features (e.g., `music.play`, `dashboard.edit`).
|
||||
Can be manually defined or auto-discovered from an app’s `.well-known/guard-configuration` endpoint.
|
||||
|
||||
### 🧩 **Roles**
|
||||
Named bundles of permissions (e.g., `GUEST`, `FAMILY_MEMBER`, `ADMIN`).
|
||||
Assign to users/groups for easier management.
|
||||
|
||||
### 👥 **Groups**
|
||||
(Planned) Logical user collections (e.g., “Family”, “Guests”, “Admins”) for batch management of roles/permissions.
|
||||
|
||||
### 👤 **Users**
|
||||
Each user has a unique profile, roles, and group memberships.
|
||||
|
||||
---
|
||||
|
||||
### 👥 Group *(Coming Soon)*
|
||||
## 🔗 OIDC/OAuth Integration
|
||||
|
||||
This feature is planned for future releases. Groups will help organize users or services into logical clusters for simplified access control.
|
||||
**HSP Guard** is a standard-compliant OIDC Provider. Any app supporting OIDC/OAuth can integrate.
|
||||
|
||||
- Register app in admin panel to get `client_id` & `client_secret`
|
||||
- Configure your app’s OIDC integration (see your app’s docs)
|
||||
- Token claims include `permissions` and `roles` for easy authorization
|
||||
|
||||
#### **Example Token Claims**
|
||||
```json
|
||||
{
|
||||
"sub": "123456",
|
||||
"name": "Alex Example",
|
||||
"email": "alex@example.com",
|
||||
"roles": ["GUEST"],
|
||||
"permissions": ["dashboard.view", "music.play"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📡 API
|
||||
## 📡 **App Permission Discovery**
|
||||
|
||||
### ✅ User Authorization
|
||||
If your app supports permission discovery:
|
||||
- Expose `/.well-known/guard-configuration` endpoint listing available permissions
|
||||
- When registering in HSP Guard, auto-fetch and display for approval
|
||||
|
||||
To verify whether a request is made by a valid and authorized user, applications can require a **JWT token** as part of the request.
|
||||
This token is sent to HSP Guard, which:
|
||||
|
||||
- Validates the token
|
||||
- Returns user details (e.g., ID, name, email) for logging, auditing, or request tracing
|
||||
#### **Example guard-configuration JSON**
|
||||
```json
|
||||
{
|
||||
"permissions": [
|
||||
"dashboard.view",
|
||||
"dashboard.edit",
|
||||
"dashboard.admin"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🔑 Permission Checking
|
||||
## 🔄 **User & Admin Sessions**
|
||||
|
||||
Applications can also verify whether a user holds specific permissions before granting access to certain services or features.
|
||||
|
||||
To do this, an app sends:
|
||||
|
||||
- The user's JWT token
|
||||
- A list of required permissions
|
||||
|
||||
HSP Guard checks the user’s assigned permissions and responds with the authorization status.
|
||||
|
||||
> **Best Practice:** Applications should directly integrate with HSP Guard to enforce permission-based access control.
|
||||
- List all active sessions (browser, app, device, timestamp)
|
||||
- Revoke sessions (logout) from user or admin panel
|
||||
|
||||
---
|
||||
|
||||
## 🔄 User Authorization Flow
|
||||
## 📦 **Planned Features & Roadmap**
|
||||
|
||||
When a user tries to access a home lab service that requires authentication:
|
||||
|
||||
1. The application will **offer an authorization URL** to the user.
|
||||
2. The user follows the URL and is taken to the **HSP Guard login page**.
|
||||
3. The user selects or signs into an account they wish to use for that service.
|
||||
4. Once authenticated and authorized, the user is redirected to the **application-defined redirect URL**.
|
||||
5. The application can now:
|
||||
- Retrieve a **JWT token** from the redirect callback
|
||||
- **Optionally cache the session/token** to avoid prompting the user every time
|
||||
|
||||
This process is similar to how external identity providers like **Google Sign-In** or **GitHub OAuth** work — providing a seamless and secure authentication experience for the user.
|
||||
- [ ] **Group Management** for batch assignments
|
||||
- [ ] **Audit Logging** of all admin/user actions
|
||||
- [ ] **Permission Expiry** (time-limited access)
|
||||
- [ ] **Advanced Web UI** (dark mode, mobile)
|
||||
- [ ] **External Identity Providers** (login with Google, GitHub, etc.)
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Integrating New Services & Tools
|
||||
## 🛠️ **Development**
|
||||
|
||||
When a new service or tool is installed:
|
||||
|
||||
1. It provides a configuration file to HSP Guard
|
||||
2. Guard extracts and registers any defined permissions
|
||||
3. These permissions are **isolated** — even if a name overlaps with existing permissions, a prefix is added to avoid conflicts
|
||||
- See [CONTRIBUTING.md](CONTRIBUTING.md) for how to contribute!
|
||||
- Pull requests and issues are welcome.
|
||||
|
||||
---
|
||||
|
||||
## 👤 User Registration & Onboarding
|
||||
## 📝 **License**
|
||||
|
||||
New users (e.g., family, friends, guests) must complete a registration process to access your home lab.
|
||||
|
||||
They can:
|
||||
|
||||
- Visit a user-friendly registration webpage
|
||||
- Fill out a form with basic information (name, email, password, etc.)
|
||||
|
||||
Once registered, the admin can assign roles or individual permissions as needed.
|
||||
MIT — open source, for the home lab community.
|
||||
|
||||
---
|
||||
|
||||
## 🚧 Roadmap
|
||||
## 💬 **Feedback**
|
||||
|
||||
- [ ] Group Management
|
||||
- [ ] Web UI Enhancements
|
||||
- [ ] Audit Logging
|
||||
- [ ] Permission Expiry & Time-Based Access
|
||||
Open an [issue](https://github.com/yourusername/hsp-guard/issues) or join the discussion!
|
||||
|
||||
---
|
||||
|
||||
## 📬 Feedback & Contribution
|
||||
|
||||
Feel free to open an issue or pull request if you’d like to contribute or report bugs. HSP Guard is a personal home lab project, but feedback is always welcome!
|
||||
|
@ -8,6 +8,7 @@ import (
|
||||
|
||||
"gitea.local/admin/hspguard/internal/admin"
|
||||
"gitea.local/admin/hspguard/internal/auth"
|
||||
"gitea.local/admin/hspguard/internal/cache"
|
||||
"gitea.local/admin/hspguard/internal/config"
|
||||
"gitea.local/admin/hspguard/internal/oauth"
|
||||
"gitea.local/admin/hspguard/internal/repository"
|
||||
@ -21,14 +22,16 @@ 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, cfg *config.AppConfig) *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,
|
||||
}
|
||||
}
|
||||
@ -41,13 +44,13 @@ func (s *APIServer) Run() error {
|
||||
// staticDir := http.Dir(filepath.Join(workDir, "static"))
|
||||
// FileServer(router, "/static", staticDir)
|
||||
|
||||
oauthHandler := oauth.NewOAuthHandler(s.repo, s.cfg)
|
||||
oauthHandler := oauth.NewOAuthHandler(s.repo, s.cache, s.cfg)
|
||||
|
||||
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, s.cfg)
|
||||
authHandler := auth.NewAuthHandler(s.repo, s.cache, s.cfg)
|
||||
authHandler.RegisterRoutes(r)
|
||||
|
||||
oauthHandler.RegisterRoutes(r)
|
||||
|
@ -7,6 +7,7 @@ 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"
|
||||
@ -41,9 +42,12 @@ func main() {
|
||||
|
||||
fStorage := storage.New(&cfg)
|
||||
|
||||
user.EnsureAdminUser(ctx, &cfg, repo)
|
||||
cache := cache.NewClient(&cfg)
|
||||
|
||||
server := api.NewAPIServer(fmt.Sprintf("%s:%s", cfg.Host, cfg.Port), repo, fStorage, &cfg)
|
||||
user.EnsureAdminUser(ctx, &cfg, repo)
|
||||
user.EnsureSystemPermissions(ctx, repo)
|
||||
|
||||
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)
|
||||
}
|
||||
|
@ -6,5 +6,24 @@ services:
|
||||
environment:
|
||||
POSTGRES_USER: guard
|
||||
POSTGRES_PASSWORD: guard
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
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
|
||||
postgres-data:
|
||||
driver: local
|
||||
|
4
go.mod
4
go.mod
@ -11,6 +11,9 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/avct/uasurfer v0.0.0-20250506104815-f2613aa2d406 // indirect
|
||||
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
|
||||
@ -23,6 +26,7 @@ require (
|
||||
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
|
||||
|
8
go.sum
8
go.sum
@ -1,6 +1,12 @@
|
||||
github.com/avct/uasurfer v0.0.0-20250506104815-f2613aa2d406 h1:5/KfwL9TS8yNtUSunutqifcSC8rdX9PNdvbSsw/X/lQ=
|
||||
github.com/avct/uasurfer v0.0.0-20250506104815-f2613aa2d406/go.mod h1:s+GCtuP4kZNxh1WGoqdWI1+PbluBcycrMMWuKQ9e5Nk=
|
||||
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 +44,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=
|
||||
|
@ -4,43 +4,15 @@ import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"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/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"`
|
||||
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,
|
||||
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 {
|
||||
@ -49,14 +21,14 @@ func (h *AdminHandler) GetApiServices(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
apiServices := make([]ApiServiceDTO, 0)
|
||||
apiServices := make([]types.ApiServiceDTO, 0)
|
||||
|
||||
for _, service := range services {
|
||||
apiServices = append(apiServices, NewApiServiceDTO(service))
|
||||
apiServices = append(apiServices, types.NewApiServiceDTO(service))
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
Items []ApiServiceDTO `json:"items"`
|
||||
Items []types.ApiServiceDTO `json:"items"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
@ -144,7 +116,7 @@ func (h *AdminHandler) AddApiService(w http.ResponseWriter, r *http.Request) {
|
||||
service.ClientSecret = clientSecret
|
||||
|
||||
type Response struct {
|
||||
Service ApiServiceDTO `json:"service"`
|
||||
Service types.ApiServiceDTO `json:"service"`
|
||||
Credentials ApiServiceCredentials `json:"credentials"`
|
||||
}
|
||||
|
||||
@ -153,7 +125,7 @@ func (h *AdminHandler) AddApiService(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(Response{
|
||||
Service: NewApiServiceDTO(service),
|
||||
Service: types.NewApiServiceDTO(service),
|
||||
Credentials: ApiServiceCredentials{
|
||||
ClientId: service.ClientID,
|
||||
ClientSecret: service.ClientSecret,
|
||||
@ -181,7 +153,25 @@ func (h *AdminHandler) GetApiService(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(NewApiServiceDTO(service)); err != nil {
|
||||
if err := encoder.Encode(types.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(types.NewApiServiceDTO(service)); err != nil {
|
||||
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
@ -283,7 +273,7 @@ func (h *AdminHandler) UpdateApiService(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(NewApiServiceDTO(updated)); err != nil {
|
||||
if err := encoder.Encode(types.NewApiServiceDTO(updated)); err != nil {
|
||||
web.Error(w, "failed to send updated api service", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
82
internal/admin/permissions.go
Normal file
82
internal/admin/permissions.go
Normal file
@ -0,0 +1,82 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/repository"
|
||||
"gitea.local/admin/hspguard/internal/web"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) GetAllPermissions(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := h.repo.GetGroupedPermissions(r.Context())
|
||||
if err != nil {
|
||||
log.Println("ERR: Failed to list permissions from db:", err)
|
||||
web.Error(w, "failed to get all permissions", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
type RowDTO struct {
|
||||
Scope string `json:"scope"`
|
||||
Permissions []repository.Permission `json:"permissions"`
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
rows = make([]repository.GetGroupedPermissionsRow, 0)
|
||||
}
|
||||
|
||||
var mapped []RowDTO
|
||||
|
||||
for _, row := range rows {
|
||||
var permissions []repository.Permission
|
||||
|
||||
if err := json.Unmarshal(row.Permissions, &permissions); err != nil {
|
||||
log.Println("ERR: Failed to extract permissions from byte array:", err)
|
||||
web.Error(w, "failed to get permissions", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
mapped = append(mapped, RowDTO{
|
||||
Scope: row.Scope,
|
||||
Permissions: permissions,
|
||||
})
|
||||
}
|
||||
|
||||
if len(mapped) == 0 {
|
||||
mapped = make([]RowDTO, 0)
|
||||
}
|
||||
|
||||
encoder := json.NewEncoder(w)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(mapped); err != nil {
|
||||
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetUserPermissions(w http.ResponseWriter, r *http.Request) {
|
||||
userId := chi.URLParam(r, "user_id")
|
||||
|
||||
permissions, err := h.repo.GetUserPermissions(r.Context(), uuid.MustParse(userId))
|
||||
if err != nil {
|
||||
log.Println("ERR: Failed to list permissions from db:", err)
|
||||
web.Error(w, "failed to get user permissions", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if len(permissions) == 0 {
|
||||
permissions = make([]repository.Permission, 0)
|
||||
}
|
||||
|
||||
encoder := json.NewEncoder(w)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(permissions); err != nil {
|
||||
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
156
internal/admin/roles.go
Normal file
156
internal/admin/roles.go
Normal file
@ -0,0 +1,156 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/repository"
|
||||
"gitea.local/admin/hspguard/internal/web"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) GetAllRoles(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := h.repo.GetRolesGroupedWithPermissions(r.Context())
|
||||
if err != nil {
|
||||
log.Println("ERR: Failed to list roles from db:", err)
|
||||
web.Error(w, "failed to get all roles", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
type RolePermissions struct {
|
||||
Permissions []repository.Permission `json:"permissions"`
|
||||
repository.Role
|
||||
}
|
||||
|
||||
type RowDTO struct {
|
||||
Scope string `json:"scope"`
|
||||
Roles []RolePermissions `json:"roles"`
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
rows = make([]repository.GetRolesGroupedWithPermissionsRow, 0)
|
||||
}
|
||||
|
||||
var mapped []RowDTO
|
||||
|
||||
for _, row := range rows {
|
||||
var mappedRow RowDTO
|
||||
|
||||
mappedRow.Scope = row.Scope
|
||||
|
||||
if err := json.Unmarshal(row.Roles, &mappedRow.Roles); err != nil {
|
||||
log.Println("ERR: Failed to extract roles from byte array:", err)
|
||||
web.Error(w, "failed to get roles", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
mapped = append(mapped, mappedRow)
|
||||
}
|
||||
|
||||
if len(mapped) == 0 {
|
||||
mapped = make([]RowDTO, 0)
|
||||
}
|
||||
|
||||
encoder := json.NewEncoder(w)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(mapped); err != nil {
|
||||
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetUserRoles(w http.ResponseWriter, r *http.Request) {
|
||||
userId := chi.URLParam(r, "user_id")
|
||||
|
||||
parsed, err := uuid.Parse(userId)
|
||||
if err != nil {
|
||||
log.Printf("ERR: Received invalid UUID on get user roles '%s': %v\n", userId, err)
|
||||
web.Error(w, "invalid user id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := h.repo.GetUserRoles(r.Context(), parsed)
|
||||
if err != nil {
|
||||
log.Println("ERR: Failed to list roles from db:", err)
|
||||
web.Error(w, "failed to get user roles", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
rows = make([]repository.Role, 0)
|
||||
}
|
||||
|
||||
encoder := json.NewEncoder(w)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(rows); err != nil {
|
||||
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
type AssignRoleRequest struct {
|
||||
RoleKey string `json:"role_key"`
|
||||
}
|
||||
|
||||
func (h *AdminHandler) AssignUserRole(w http.ResponseWriter, r *http.Request) {
|
||||
userId := chi.URLParam(r, "user_id")
|
||||
|
||||
var req AssignRoleRequest
|
||||
|
||||
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.RoleKey == "" {
|
||||
web.Error(w, "role key is required for assign", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
parsed, err := uuid.Parse(userId)
|
||||
if err != nil {
|
||||
log.Printf("ERR: Failed to parse provided user ID '%s': %v\n", userId, err)
|
||||
web.Error(w, "invalid user id provided", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.repo.FindUserId(r.Context(), parsed)
|
||||
if err != nil {
|
||||
web.Error(w, "no user found under provided id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := h.repo.FindUserRole(r.Context(), repository.FindUserRoleParams{
|
||||
UserID: user.ID,
|
||||
Key: req.RoleKey,
|
||||
}); err == nil {
|
||||
log.Printf("INFO: Unassigning role '%s' for user with '%s' id", req.RoleKey, user.ID.String())
|
||||
// Unassign Role
|
||||
if err := h.repo.UnassignUserRole(r.Context(), repository.UnassignUserRoleParams{
|
||||
UserID: user.ID,
|
||||
Key: req.RoleKey,
|
||||
}); err != nil {
|
||||
log.Printf("ERR: Failed to unassign role '%s' from user with '%s' id: %v\n", req.RoleKey, user.ID.String(), err)
|
||||
web.Error(w, "failed to unassign role to user", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
log.Printf("INFO: Assigning role '%s' for user with '%s' id", req.RoleKey, user.ID.String())
|
||||
if err := h.repo.AssignUserRole(r.Context(), repository.AssignUserRoleParams{
|
||||
UserID: user.ID,
|
||||
Key: req.RoleKey,
|
||||
}); err != nil {
|
||||
log.Printf("ERR: Failed to assign role '%s' to user with '%s' id: %v\n", req.RoleKey, user.ID.String(), err)
|
||||
web.Error(w, "failed to assign role to user", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
@ -21,7 +21,7 @@ func New(repo *repository.Queries, cfg *config.AppConfig) *AdminHandler {
|
||||
|
||||
func (h *AdminHandler) RegisterRoutes(router chi.Router) {
|
||||
router.Route("/admin", func(r chi.Router) {
|
||||
authMiddleware := imiddleware.NewAuthMiddleware(h.cfg)
|
||||
authMiddleware := imiddleware.NewAuthMiddleware(h.cfg, h.repo)
|
||||
adminMiddleware := imiddleware.NewAdminMiddleware(h.repo)
|
||||
r.Use(authMiddleware.Runner, adminMiddleware.Runner)
|
||||
|
||||
@ -35,5 +35,20 @@ func (h *AdminHandler) RegisterRoutes(router chi.Router) {
|
||||
r.Get("/users", h.GetUsers)
|
||||
r.Post("/users", h.CreateUser)
|
||||
r.Get("/users/{id}", h.GetUser)
|
||||
|
||||
r.Get("/user-sessions", h.GetUserSessions)
|
||||
r.Patch("/user-sessions/revoke/{id}", h.RevokeUserSession)
|
||||
|
||||
r.Get("/service-sessions", h.GetServiceSessions)
|
||||
r.Patch("/service-sessions/revoke/{id}", h.RevokeUserSession)
|
||||
|
||||
r.Get("/permissions", h.GetAllPermissions)
|
||||
r.Get("/permissions/{user_id}", h.GetUserPermissions)
|
||||
|
||||
r.Get("/roles", h.GetAllRoles)
|
||||
r.Get("/roles/{user_id}", h.GetUserRoles)
|
||||
r.Patch("/roles/{user_id}", h.AssignUserRole)
|
||||
})
|
||||
|
||||
router.Get("/api-services/client/{client_id}", h.GetApiServiceCID)
|
||||
}
|
||||
|
182
internal/admin/sessions.go
Normal file
182
internal/admin/sessions.go
Normal file
@ -0,0 +1,182 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/repository"
|
||||
"gitea.local/admin/hspguard/internal/types"
|
||||
"gitea.local/admin/hspguard/internal/web"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type GetSessionsParams struct {
|
||||
PageSize int `json:"size"`
|
||||
Page int `json:"page"`
|
||||
// TODO: More filtering possibilities like onlyActive, expired, not-expired etc.
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetUserSessions(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
|
||||
params := GetSessionsParams{}
|
||||
|
||||
if pageSize, err := strconv.Atoi(q.Get("size")); err == nil {
|
||||
params.PageSize = pageSize
|
||||
} else {
|
||||
params.PageSize = 15
|
||||
}
|
||||
|
||||
if page, err := strconv.Atoi(q.Get("page")); err == nil {
|
||||
params.Page = page
|
||||
} else {
|
||||
web.Error(w, "page is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
sessions, err := h.repo.GetUserSessions(r.Context(), repository.GetUserSessionsParams{
|
||||
Limit: int32(params.PageSize),
|
||||
Offset: int32(params.Page-1) * int32(params.PageSize),
|
||||
})
|
||||
if err != nil {
|
||||
log.Println("ERR: Failed to read user sessions from db:", err)
|
||||
web.Error(w, "failed to retrieve sessions", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
totalSessions, err := h.repo.GetUserSessionsCount(r.Context())
|
||||
if err != nil {
|
||||
log.Println("ERR: Failed to get total count of user sessions:", err)
|
||||
web.Error(w, "failed to retrieve sessions", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
mapped := make([]*types.UserSessionDTO, 0)
|
||||
|
||||
for _, session := range sessions {
|
||||
mapped = append(mapped, types.NewUserSessionDTO(&session))
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
Items []*types.UserSessionDTO `json:"items"`
|
||||
Page int `json:"page"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
response := Response{
|
||||
Items: mapped,
|
||||
Page: params.Page,
|
||||
TotalPages: int(math.Ceil(float64(totalSessions) / float64(params.PageSize))),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
log.Println("ERR: Failed to encode sessions in response:", err)
|
||||
web.Error(w, "failed to encode sessions", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AdminHandler) RevokeUserSession(w http.ResponseWriter, r *http.Request) {
|
||||
sessionId := chi.URLParam(r, "id")
|
||||
parsed, err := uuid.Parse(sessionId)
|
||||
if err != nil {
|
||||
web.Error(w, "provided service id is not valid", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.RevokeUserSession(r.Context(), parsed); err != nil {
|
||||
log.Println("ERR: Failed to revoke user session:", err)
|
||||
web.Error(w, "failed to revoke user session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("{\"success\":true}"))
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetServiceSessions(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
|
||||
params := GetSessionsParams{}
|
||||
|
||||
if pageSize, err := strconv.Atoi(q.Get("size")); err == nil {
|
||||
params.PageSize = pageSize
|
||||
} else {
|
||||
params.PageSize = 15
|
||||
}
|
||||
|
||||
if page, err := strconv.Atoi(q.Get("page")); err == nil {
|
||||
params.Page = page
|
||||
} else {
|
||||
web.Error(w, "page is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
sessions, err := h.repo.GetServiceSessions(r.Context(), repository.GetServiceSessionsParams{
|
||||
Limit: int32(params.PageSize),
|
||||
Offset: int32(params.Page-1) * int32(params.PageSize),
|
||||
})
|
||||
if err != nil {
|
||||
log.Println("ERR: Failed to read api sessions from db:", err)
|
||||
web.Error(w, "failed to retrieve sessions", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
totalSessions, err := h.repo.GetServiceSessionsCount(r.Context())
|
||||
if err != nil {
|
||||
log.Println("ERR: Failed to get total count of service sessions:", err)
|
||||
web.Error(w, "failed to retrieve sessions", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
mapped := make([]*types.ServiceSessionDTO, 0)
|
||||
|
||||
for _, session := range sessions {
|
||||
mapped = append(mapped, types.NewServiceSessionDTO(&session))
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
Items []*types.ServiceSessionDTO `json:"items"`
|
||||
Page int `json:"page"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
response := Response{
|
||||
Items: mapped,
|
||||
Page: params.Page,
|
||||
TotalPages: int(math.Ceil(float64(totalSessions) / float64(params.PageSize))),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
log.Println("ERR: Failed to encode sessions in response:", err)
|
||||
web.Error(w, "failed to encode sessions", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AdminHandler) RevokeServiceSession(w http.ResponseWriter, r *http.Request) {
|
||||
sessionId := chi.URLParam(r, "id")
|
||||
parsed, err := uuid.Parse(sessionId)
|
||||
if err != nil {
|
||||
web.Error(w, "provided service id is not valid", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.RevokeServiceSession(r.Context(), parsed); err != nil {
|
||||
log.Println("ERR: Failed to revoke service session:", err)
|
||||
web.Error(w, "failed to revoke service session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("{\"success\":true}"))
|
||||
}
|
@ -13,20 +13,6 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func NewUserDTO(row *repository.User) types.UserDTO {
|
||||
return types.UserDTO{
|
||||
ID: row.ID,
|
||||
Email: row.Email,
|
||||
FullName: row.FullName,
|
||||
IsAdmin: row.IsAdmin,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
LastLogin: row.LastLogin,
|
||||
PhoneNumber: row.PhoneNumber,
|
||||
ProfilePicture: row.ProfilePicture,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetUsers(w http.ResponseWriter, r *http.Request) {
|
||||
userId, ok := util.GetRequestUserId(r.Context())
|
||||
if !ok {
|
||||
@ -55,7 +41,7 @@ func (h *AdminHandler) GetUsers(w http.ResponseWriter, r *http.Request) {
|
||||
var items []types.UserDTO
|
||||
|
||||
for _, user := range users {
|
||||
items = append(items, NewUserDTO(&user))
|
||||
items = append(items, types.NewUserDTO(&user))
|
||||
}
|
||||
|
||||
encoder := json.NewEncoder(w)
|
||||
@ -88,7 +74,7 @@ func (h *AdminHandler) GetUser(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(NewUserDTO(&user)); err != nil {
|
||||
if err := encoder.Encode(types.NewUserDTO(&user)); err != nil {
|
||||
web.Error(w, "failed to encode user dto", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
107
internal/auth/login.go
Normal file
107
internal/auth/login.go
Normal file
@ -0,0 +1,107 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/repository"
|
||||
"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(¶ms); 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 {
|
||||
log.Printf("DEBUG: No user found with '%s' email: %v\n", params.Email, err)
|
||||
web.Error(w, "email or/and password are incorrect", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if !util.VerifyPassword(params.Password, user.PasswordHash) {
|
||||
log.Printf("DEBUG: Incorrect password '%s' for '%s' email: %v\n", params.Password, params.Email, err)
|
||||
web.Error(w, "email 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
|
||||
}
|
||||
|
||||
userAgent := r.UserAgent()
|
||||
|
||||
ipAddr := util.GetClientIP(r)
|
||||
deviceInfo := util.BuildDeviceInfo(userAgent, ipAddr)
|
||||
|
||||
// Create User Session
|
||||
session, err := h.repo.CreateUserSession(r.Context(), repository.CreateUserSessionParams{
|
||||
UserID: user.ID,
|
||||
SessionType: "user",
|
||||
ExpiresAt: &refresh.ExpiresAt,
|
||||
LastActive: nil,
|
||||
IpAddress: &ipAddr,
|
||||
UserAgent: &userAgent,
|
||||
AccessTokenID: &access.ID,
|
||||
RefreshTokenID: &refresh.ID,
|
||||
DeviceInfo: deviceInfo,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("ERR: Failed to create user session after logging in: %v\n", err)
|
||||
}
|
||||
|
||||
log.Printf("INFO: User session created for '%s' with '%s' id\n", user.Email, session.ID.String())
|
||||
|
||||
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.Token,
|
||||
RefreshToken: refresh.Token,
|
||||
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
31
internal/auth/profile.go
Normal 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)
|
||||
}
|
||||
}
|
122
internal/auth/refresh.go
Normal file
122
internal/auth/refresh.go
Normal file
@ -0,0 +1,122 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"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/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
|
||||
}
|
||||
|
||||
jti, err := uuid.Parse(userClaims.ID)
|
||||
if session, err := h.repo.GetUserSessionByRefreshJTI(r.Context(), &jti); err != nil {
|
||||
log.Printf("WARN: No existing user session found for user with '%s' email (jti: '%s'): %v\n", user.Email, userClaims.ID, err)
|
||||
|
||||
userAgent := r.UserAgent()
|
||||
|
||||
ipAddr := util.GetClientIP(r)
|
||||
deviceInfo := util.BuildDeviceInfo(userAgent, ipAddr)
|
||||
|
||||
// Create User Session
|
||||
session, err := h.repo.CreateUserSession(r.Context(), repository.CreateUserSessionParams{
|
||||
UserID: user.ID,
|
||||
SessionType: "user",
|
||||
ExpiresAt: &refresh.ExpiresAt,
|
||||
LastActive: nil,
|
||||
IpAddress: &ipAddr,
|
||||
UserAgent: &userAgent,
|
||||
AccessTokenID: &access.ID,
|
||||
RefreshTokenID: &refresh.ID,
|
||||
DeviceInfo: deviceInfo,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("ERR: Failed to create user session after logging in: %v\n", err)
|
||||
}
|
||||
|
||||
log.Printf("INFO: User session created for '%s' with '%s' id\n", user.Email, session.ID.String())
|
||||
} else {
|
||||
err := h.repo.UpdateSessionTokens(r.Context(), repository.UpdateSessionTokensParams{
|
||||
ID: session.ID,
|
||||
AccessTokenID: &access.ID,
|
||||
RefreshTokenID: &refresh.ID,
|
||||
ExpiresAt: &refresh.ExpiresAt,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("ERR: Failed to update user session with '%s' id: %v\n", session.ID.String(), err)
|
||||
}
|
||||
}
|
||||
|
||||
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.Token,
|
||||
RefreshToken: refresh.Token,
|
||||
}); err != nil {
|
||||
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
@ -1,19 +1,14 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"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"
|
||||
@ -21,10 +16,14 @@ import (
|
||||
|
||||
type AuthHandler struct {
|
||||
repo *repository.Queries
|
||||
cache *cache.Client
|
||||
cfg *config.AppConfig
|
||||
}
|
||||
|
||||
func (h *AuthHandler) signTokens(user *repository.User) (string, string, error) {
|
||||
func (h *AuthHandler) signTokens(user *repository.User) (*types.SignedToken, *types.SignedToken, error) {
|
||||
accessExpiresAt := time.Now().Add(15 * time.Minute)
|
||||
accessJTI := uuid.New()
|
||||
|
||||
accessClaims := types.UserClaims{
|
||||
UserEmail: user.Email,
|
||||
IsAdmin: user.IsAdmin,
|
||||
@ -32,15 +31,19 @@ func (h *AuthHandler) signTokens(user *repository.User) (string, string, error)
|
||||
Issuer: h.cfg.Uri,
|
||||
Subject: user.ID.String(),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
|
||||
ExpiresAt: jwt.NewNumericDate(accessExpiresAt),
|
||||
ID: accessJTI.String(),
|
||||
},
|
||||
}
|
||||
|
||||
accessToken, err := util.SignJwtToken(accessClaims, h.cfg.Jwt.PrivateKey)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
refreshExpiresAt := time.Now().Add(30 * 24 * time.Hour)
|
||||
refreshJTI := uuid.New()
|
||||
|
||||
refreshClaims := types.UserClaims{
|
||||
UserEmail: user.Email,
|
||||
IsAdmin: user.IsAdmin,
|
||||
@ -48,21 +51,23 @@ func (h *AuthHandler) signTokens(user *repository.User) (string, string, error)
|
||||
Issuer: h.cfg.Uri,
|
||||
Subject: user.ID.String(),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * 24 * time.Hour)),
|
||||
ExpiresAt: jwt.NewNumericDate(refreshExpiresAt),
|
||||
ID: refreshJTI.String(),
|
||||
},
|
||||
}
|
||||
|
||||
refreshToken, err := util.SignJwtToken(refreshClaims, h.cfg.Jwt.PrivateKey)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return accessToken, refreshToken, nil
|
||||
return types.NewSignedToken(accessToken, accessExpiresAt, accessJTI), types.NewSignedToken(refreshToken, refreshExpiresAt, refreshJTI), nil
|
||||
}
|
||||
|
||||
func NewAuthHandler(repo *repository.Queries, cfg *config.AppConfig) *AuthHandler {
|
||||
func NewAuthHandler(repo *repository.Queries, cache *cache.Client, cfg *config.AppConfig) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
repo,
|
||||
cache,
|
||||
cfg,
|
||||
}
|
||||
}
|
||||
@ -70,180 +75,17 @@ func NewAuthHandler(repo *repository.Queries, cfg *config.AppConfig) *AuthHandle
|
||||
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)
|
||||
authMiddleware := imiddleware.NewAuthMiddleware(h.cfg, h.repo)
|
||||
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)
|
||||
protected.Post("/signout", h.signOut)
|
||||
})
|
||||
|
||||
r.Post("/login", h.login)
|
||||
r.Post("/refresh", h.refreshToken)
|
||||
})
|
||||
}
|
||||
|
||||
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]
|
||||
token, userClaims, err := util.VerifyToken(tokenStr, h.cfg.Jwt.PublicKey)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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.UserDTO{
|
||||
ID: user.ID,
|
||||
FullName: user.FullName,
|
||||
Email: user.Email,
|
||||
PhoneNumber: user.PhoneNumber,
|
||||
IsAdmin: user.IsAdmin,
|
||||
LastLogin: user.LastLogin,
|
||||
ProfilePicture: user.ProfilePicture,
|
||||
UpdatedAt: user.UpdatedAt,
|
||||
CreatedAt: user.CreatedAt,
|
||||
}); err != nil {
|
||||
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(¶ms); 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)
|
||||
}
|
||||
}
|
||||
|
40
internal/auth/signout.go
Normal file
40
internal/auth/signout.go
Normal file
@ -0,0 +1,40 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/util"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (h *AuthHandler) signOut(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("{\"status\": \"ok\"}"))
|
||||
}()
|
||||
|
||||
jti, ok := util.GetRequestJTI(r.Context())
|
||||
if !ok {
|
||||
log.Println("WARN: No JTI found in request")
|
||||
return
|
||||
}
|
||||
|
||||
jtiId, err := uuid.Parse(jti)
|
||||
if err != nil {
|
||||
log.Printf("ERR: Failed to parse jti '%s' as v4 uuid: %v\n", jti, err)
|
||||
return
|
||||
}
|
||||
|
||||
session, err := h.repo.GetUserSessionByAccessJTI(r.Context(), &jtiId)
|
||||
if err != nil {
|
||||
log.Printf("WARN: Could not find session by jti id '%s': %v\n", jtiId.String(), err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.RevokeUserSession(r.Context(), session.ID); err != nil {
|
||||
log.Printf("ERR: Failed to revoke session with '%s' id: %v\n", session.ID.String(), err)
|
||||
} else {
|
||||
log.Printf("INFO: Revoked session with jti = '%s' and session id = '%s'\n", jtiId.String(), session.ID.String())
|
||||
}
|
||||
}
|
126
internal/auth/verify.go
Normal file
126
internal/auth/verify.go
Normal 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
84
internal/cache/mod.go
vendored
Normal 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)
|
||||
}
|
@ -14,6 +14,7 @@ type AppConfig struct {
|
||||
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
|
||||
|
@ -3,22 +3,27 @@ package middleware
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/config"
|
||||
"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/google/uuid"
|
||||
)
|
||||
|
||||
type AuthMiddleware struct {
|
||||
cfg *config.AppConfig
|
||||
repo *repository.Queries
|
||||
}
|
||||
|
||||
func NewAuthMiddleware(cfg *config.AppConfig) *AuthMiddleware {
|
||||
func NewAuthMiddleware(cfg *config.AppConfig, repo *repository.Queries) *AuthMiddleware {
|
||||
return &AuthMiddleware{
|
||||
cfg,
|
||||
repo,
|
||||
}
|
||||
}
|
||||
|
||||
@ -37,13 +42,36 @@ func (m *AuthMiddleware) Runner(next http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
tokenStr := parts[1]
|
||||
token, userClaims, err := util.VerifyToken(tokenStr, m.cfg.Jwt.PublicKey)
|
||||
var userClaims types.UserClaims
|
||||
|
||||
token, err := util.VerifyToken(tokenStr, m.cfg.Jwt.PublicKey, &userClaims)
|
||||
if err != nil || !token.Valid {
|
||||
web.Error(w, fmt.Sprintf("invalid token: %v", err), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: redis caching
|
||||
parsed, err := uuid.Parse(userClaims.ID)
|
||||
if err != nil {
|
||||
log.Printf("ERR: Failed to parse token JTI '%s': %v\n", userClaims.ID, err)
|
||||
web.Error(w, "failed to get session", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
session, err := m.repo.GetUserSessionByAccessJTI(r.Context(), &parsed)
|
||||
if err != nil {
|
||||
log.Printf("ERR: Failed to find session with '%s' JTI: %v\n", parsed.String(), err)
|
||||
web.Error(w, "no session found", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if !session.IsActive {
|
||||
log.Printf("INFO: Inactive session trying to authorize: %s\n", session.AccessTokenID)
|
||||
web.Error(w, "no session found", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), types.UserIdKey, userClaims.Subject)
|
||||
ctx = context.WithValue(ctx, types.JTIKey, userClaims.ID)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
@ -3,7 +3,6 @@ package oauth
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/web"
|
||||
@ -29,37 +28,17 @@ func (h *OAuthHandler) AuthorizeClient(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
}
|
||||
scopes := strings.Split(strings.TrimSpace(r.URL.Query().Get("scope")), " ")
|
||||
|
||||
if uri, err := h.verifyOAuthClient(r.Context(), &VerifyOAuthClientParams{
|
||||
ClientID: clientId,
|
||||
RedirectURI: &redirectUri,
|
||||
State: state,
|
||||
Scopes: &scopes,
|
||||
}); err != nil {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("/auth?%s", r.URL.Query().Encode()), http.StatusFound)
|
||||
}
|
||||
|
58
internal/oauth/client.go
Normal file
58
internal/oauth/client.go
Normal file
@ -0,0 +1,58 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type VerifyOAuthClientParams struct {
|
||||
ClientID string `json:"client_id"`
|
||||
RedirectURI *string `json:"redirect_uri"`
|
||||
State string `json:"state"`
|
||||
Scopes *[]string `json:"scopes"`
|
||||
}
|
||||
|
||||
func (h *OAuthHandler) verifyOAuthClient(ctx context.Context, params *VerifyOAuthClientParams) (string, error) {
|
||||
client, err := h.repo.GetApiServiceCID(ctx, params.ClientID)
|
||||
if err != nil {
|
||||
uri := fmt.Sprintf("%s?error=access_denied&error_description=Service+not+authorized", *params.RedirectURI)
|
||||
if params.State != "" {
|
||||
uri += "&state=" + params.State
|
||||
}
|
||||
return uri, fmt.Errorf("target oauth service with client id '%s' is not registered", params.ClientID)
|
||||
}
|
||||
|
||||
if !client.IsActive {
|
||||
uri := fmt.Sprintf("%s?error=temporarily_unavailable&error_description=Service+not+active", *params.RedirectURI)
|
||||
if params.State != "" {
|
||||
uri += "&state=" + params.State
|
||||
}
|
||||
return uri, fmt.Errorf("target oauth service with client id '%s' is not available", client.ClientID)
|
||||
}
|
||||
|
||||
if params.Scopes != nil {
|
||||
for _, scope := range *params.Scopes {
|
||||
if !slices.Contains(client.Scopes, scope) {
|
||||
uri := fmt.Sprintf("%s?error=invalid_scope&error_description=Scope+%s+is+not+allowed", *params.RedirectURI, strings.ReplaceAll(scope, " ", "+"))
|
||||
if params.State != "" {
|
||||
uri += "&state=" + params.State
|
||||
}
|
||||
return uri, fmt.Errorf("unallowed scope '%s' requested", scope)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if params.RedirectURI != nil {
|
||||
if !slices.Contains(client.RedirectUris, *params.RedirectURI) {
|
||||
uri := fmt.Sprintf("%s?error=invalid_request&error_description=Redirect+URI+is+not+allowed", *params.RedirectURI)
|
||||
if params.State != "" {
|
||||
uri += "&state=" + params.State
|
||||
}
|
||||
return uri, fmt.Errorf("redirect uri '%s' is unallowed", *params.RedirectURI)
|
||||
}
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
@ -1,10 +1,13 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"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"
|
||||
@ -25,6 +28,7 @@ func (h *OAuthHandler) getAuthCode(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
type Request struct {
|
||||
Nonce string `json:"nonce"`
|
||||
ClientID string `json:"client_id"`
|
||||
}
|
||||
|
||||
var req Request
|
||||
@ -35,7 +39,39 @@ func (h *OAuthHandler) getAuthCode(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Create real authorization code
|
||||
if _, err := h.verifyOAuthClient(r.Context(), &VerifyOAuthClientParams{
|
||||
ClientID: req.ClientID,
|
||||
RedirectURI: nil,
|
||||
State: "",
|
||||
Scopes: nil,
|
||||
}); err != nil {
|
||||
web.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
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(), ¶ms); 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"`
|
||||
@ -46,7 +82,7 @@ func (h *OAuthHandler) getAuthCode(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(Response{
|
||||
Code: fmt.Sprintf("%s,%s", user.ID.String(), req.Nonce),
|
||||
Code: authCode,
|
||||
}); err != nil {
|
||||
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
|
@ -14,6 +14,7 @@ func (h *OAuthHandler) OpenIdConfiguration(w http.ResponseWriter, r *http.Reques
|
||||
JwksURI string `json:"jwks_uri"`
|
||||
Issuer string `json:"issuer"`
|
||||
EndSessionEndpoint string `json:"end_session_endpoint"`
|
||||
GrantTypesSupported []string `json:"grant_types_supported"`
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@ -28,6 +29,10 @@ func (h *OAuthHandler) OpenIdConfiguration(w http.ResponseWriter, r *http.Reques
|
||||
JwksURI: h.cfg.Uri + "/.well-known/jwks.json",
|
||||
Issuer: h.cfg.Uri,
|
||||
EndSessionEndpoint: h.cfg.Uri + "/api/v1/oauth/logout",
|
||||
GrantTypesSupported: []string{
|
||||
"authorization_code",
|
||||
"refresh_token",
|
||||
},
|
||||
}); err != nil {
|
||||
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
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"
|
||||
@ -9,12 +10,14 @@ import (
|
||||
|
||||
type OAuthHandler struct {
|
||||
repo *repository.Queries
|
||||
cache *cache.Client
|
||||
cfg *config.AppConfig
|
||||
}
|
||||
|
||||
func NewOAuthHandler(repo *repository.Queries, cfg *config.AppConfig) *OAuthHandler {
|
||||
func NewOAuthHandler(repo *repository.Queries, cache *cache.Client, cfg *config.AppConfig) *OAuthHandler {
|
||||
return &OAuthHandler{
|
||||
repo,
|
||||
cache,
|
||||
cfg,
|
||||
}
|
||||
}
|
||||
@ -22,7 +25,7 @@ func NewOAuthHandler(repo *repository.Queries, cfg *config.AppConfig) *OAuthHand
|
||||
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)
|
||||
authMiddleware := imiddleware.NewAuthMiddleware(h.cfg, h.repo)
|
||||
protected.Use(authMiddleware.Runner)
|
||||
|
||||
protected.Post("/code", h.getAuthCode)
|
||||
|
@ -5,10 +5,12 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"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"
|
||||
@ -16,6 +18,84 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (h *OAuthHandler) signApiTokens(user *repository.User, apiService *repository.ApiService, nonce *string) (*types.SignedToken, *types.SignedToken, *types.SignedToken, error) {
|
||||
accessExpiresIn := 15 * time.Minute
|
||||
accessExpiresAt := time.Now().Add(accessExpiresIn)
|
||||
accessJTI := uuid.New()
|
||||
|
||||
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),
|
||||
ID: accessJTI.String(),
|
||||
},
|
||||
}
|
||||
|
||||
access, err := util.SignJwtToken(accessClaims, h.cfg.Jwt.PrivateKey)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
var roles = []string{"user"}
|
||||
|
||||
if user.IsAdmin {
|
||||
roles = append(roles, "admin")
|
||||
}
|
||||
|
||||
idExpiresIn := 15 * time.Minute
|
||||
idExpiresAt := time.Now().Add(idExpiresIn)
|
||||
idJTI := uuid.New()
|
||||
|
||||
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),
|
||||
ID: idJTI.String(),
|
||||
},
|
||||
}
|
||||
|
||||
idToken, err := util.SignJwtToken(idClaims, h.cfg.Jwt.PrivateKey)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
refreshExpiresIn := 24 * time.Hour
|
||||
refreshExpiresAt := time.Now().Add(refreshExpiresIn)
|
||||
refreshJTI := uuid.New()
|
||||
|
||||
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),
|
||||
ID: refreshJTI.String(),
|
||||
},
|
||||
}
|
||||
|
||||
refresh, err := util.SignJwtToken(refreshClaims, h.cfg.Jwt.PrivateKey)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
return types.NewSignedToken(idToken, idExpiresAt, idJTI), types.NewSignedToken(access, accessExpiresAt, accessJTI), types.NewSignedToken(refresh, refreshExpiresAt, refreshJTI), nil
|
||||
}
|
||||
|
||||
func (h *OAuthHandler) tokenEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||
log.Println("[OAUTH] New request to token endpoint")
|
||||
|
||||
@ -55,75 +135,205 @@ func (h *OAuthHandler) tokenEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
grantType := r.FormValue("grant_type")
|
||||
redirectUri := r.FormValue("redirect_uri")
|
||||
|
||||
log.Printf("Redirect URI is %s\n", redirectUri)
|
||||
log.Println("DEBUG: Verifying target oauth client before proceeding...")
|
||||
|
||||
if _, err := h.verifyOAuthClient(r.Context(), &VerifyOAuthClientParams{
|
||||
ClientID: clientId,
|
||||
RedirectURI: nil,
|
||||
State: "",
|
||||
Scopes: nil,
|
||||
}); err != nil {
|
||||
web.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
switch grantType {
|
||||
case "authorization_code":
|
||||
redirectUri := r.FormValue("redirect_uri")
|
||||
log.Printf("Redirect URI is %s\n", redirectUri)
|
||||
|
||||
code := r.FormValue("code")
|
||||
|
||||
fmt.Printf("Code received: %s\n", code)
|
||||
|
||||
// TODO: Verify code from another db table
|
||||
nonce := strings.Split(code, ",")[1]
|
||||
codeSession, 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
|
||||
}
|
||||
|
||||
userId := strings.Split(code, ",")[0]
|
||||
log.Printf("DEBUG: Fetched code session: %#v\n", codeSession)
|
||||
|
||||
user, err := h.repo.FindUserId(r.Context(), uuid.MustParse(userId))
|
||||
apiService, err := h.repo.GetApiServiceCID(r.Context(), codeSession.ClientID)
|
||||
if err != nil {
|
||||
log.Printf("ERR: Could not find API service with client %s: %v\n", codeSession.ClientID, err)
|
||||
web.Error(w, "service is not registered", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if codeSession.ClientID != clientId {
|
||||
web.Error(w, "invalid auth", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.repo.FindUserId(r.Context(), uuid.MustParse(codeSession.UserID))
|
||||
if err != nil {
|
||||
web.Error(w, "requested user not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var roles = []string{"user"}
|
||||
|
||||
if user.IsAdmin {
|
||||
roles = append(roles, "admin")
|
||||
}
|
||||
|
||||
claims := types.ApiClaims{
|
||||
Email: user.Email,
|
||||
// TODO:
|
||||
EmailVerified: true,
|
||||
Name: user.FullName,
|
||||
Picture: user.ProfilePicture,
|
||||
Nonce: nonce,
|
||||
Roles: roles,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: h.cfg.Uri,
|
||||
// TODO: use dedicated API id that is in local DB and bind to user there
|
||||
Subject: user.ID.String(),
|
||||
Audience: jwt.ClaimStrings{clientId},
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
|
||||
},
|
||||
}
|
||||
|
||||
idToken, err := util.SignJwtToken(claims, h.cfg.Jwt.PrivateKey)
|
||||
id, access, refresh, err := h.signApiTokens(&user, &apiService, &codeSession.Nonce)
|
||||
if err != nil {
|
||||
web.Error(w, "failed to sign id token", http.StatusInternalServerError)
|
||||
log.Println("ERR: Failed to sign api tokens:", err)
|
||||
web.Error(w, "failed to sign tokens", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("DEBUG: Created api tokens: %v\n\n%v\n\n%v\n", id.ID.String(), access.ID.String(), refresh.ID.String())
|
||||
|
||||
userId, err := uuid.Parse(codeSession.UserID)
|
||||
if err != nil {
|
||||
log.Printf("ERR: Failed to parse user '%s' uuid: %v\n", codeSession.UserID, err)
|
||||
web.Error(w, "failed to sign tokens", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
ipAddr := util.GetClientIP(r)
|
||||
ua := r.UserAgent()
|
||||
|
||||
session, err := h.repo.CreateServiceSession(r.Context(), repository.CreateServiceSessionParams{
|
||||
ServiceID: apiService.ID,
|
||||
ClientID: apiService.ClientID,
|
||||
UserID: &userId,
|
||||
ExpiresAt: &refresh.ExpiresAt,
|
||||
LastActive: nil,
|
||||
IpAddress: &ipAddr,
|
||||
UserAgent: &ua,
|
||||
AccessTokenID: &access.ID,
|
||||
RefreshTokenID: &refresh.ID,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("ERR: Failed to create new service session: %v\n", err)
|
||||
web.Error(w, "failed to create session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("INFO: Service session created for '%s' client_id with '%s' id\n", apiService.ClientID, session.ID.String())
|
||||
|
||||
type Response struct {
|
||||
IdToken string `json:"id_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
AccessToken string `json:"access_token"`
|
||||
Email string `json:"email"`
|
||||
// TODO: add expires_in, refresh_token, scope (RFC 8693 $2)
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn float64 `json:"expires_in"`
|
||||
// TODO: add scope (RFC 8693 $2)
|
||||
}
|
||||
|
||||
response := Response{
|
||||
IdToken: idToken,
|
||||
IdToken: id.Token,
|
||||
TokenType: "Bearer",
|
||||
// FIXME:
|
||||
AccessToken: idToken,
|
||||
AccessToken: access.Token,
|
||||
RefreshToken: refresh.Token,
|
||||
ExpiresIn: math.Ceil(access.ExpiresAt.Sub(time.Now()).Seconds()),
|
||||
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
|
||||
}
|
||||
|
||||
refreshJTI, err := uuid.Parse(claims.ID)
|
||||
if err != nil {
|
||||
log.Printf("ERR: Failed to parse refresh token JTI as uuid: %v\n", err)
|
||||
web.Error(w, "failed to refresh token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
session, err := h.repo.GetServiceSessionByRefreshJTI(r.Context(), &refreshJTI)
|
||||
if err != nil {
|
||||
log.Printf("ERR: Failed to find session by '%s' refresh jti: %v\n", refreshJTI.String(), err)
|
||||
web.Error(w, "session invalid", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if !session.IsActive {
|
||||
log.Printf("INFO: Session with id '%s' is not active", session.ID.String())
|
||||
web.Error(w, "session ended", 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
|
||||
}
|
||||
|
||||
id, access, refresh, err := h.signApiTokens(&user, &apiService, nil)
|
||||
|
||||
if err := h.repo.UpdateServiceSessionTokens(r.Context(), repository.UpdateServiceSessionTokensParams{
|
||||
ID: session.ID,
|
||||
AccessTokenID: &access.ID,
|
||||
RefreshTokenID: &refresh.ID,
|
||||
ExpiresAt: &refresh.ExpiresAt,
|
||||
}); err != nil {
|
||||
log.Printf("ERR: Failed to update service session with '%s' id: %v\n", session.ID.String(), err)
|
||||
web.Error(w, "failed to update session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
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: id.Token,
|
||||
TokenType: "Bearer",
|
||||
AccessToken: access.Token,
|
||||
RefreshToken: refresh.Token,
|
||||
ExpiresIn: math.Ceil(access.ExpiresAt.Sub(time.Now()).Seconds()),
|
||||
}
|
||||
|
||||
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 {
|
||||
|
@ -28,7 +28,7 @@ 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
|
||||
) RETURNING id, client_id, client_secret, name, redirect_uris, scopes, grant_types, created_at, updated_at, is_active, description, icon_url
|
||||
`
|
||||
|
||||
type CreateApiServiceParams struct {
|
||||
@ -66,6 +66,7 @@ func (q *Queries) CreateApiService(ctx context.Context, arg CreateApiServicePara
|
||||
&i.UpdatedAt,
|
||||
&i.IsActive,
|
||||
&i.Description,
|
||||
&i.IconUrl,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@ -83,7 +84,7 @@ func (q *Queries) DeactivateApiService(ctx context.Context, clientID string) 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 FROM api_services
|
||||
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
|
||||
@ -104,12 +105,13 @@ func (q *Queries) GetApiServiceCID(ctx context.Context, clientID string) (ApiSer
|
||||
&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 FROM api_services
|
||||
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
|
||||
`
|
||||
@ -129,12 +131,13 @@ func (q *Queries) GetApiServiceId(ctx context.Context, id uuid.UUID) (ApiService
|
||||
&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 FROM api_services
|
||||
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
|
||||
`
|
||||
|
||||
@ -159,6 +162,7 @@ func (q *Queries) ListApiServices(ctx context.Context) ([]ApiService, error) {
|
||||
&i.UpdatedAt,
|
||||
&i.IsActive,
|
||||
&i.Description,
|
||||
&i.IconUrl,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -180,7 +184,7 @@ SET
|
||||
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
|
||||
RETURNING id, client_id, client_secret, name, redirect_uris, scopes, grant_types, created_at, updated_at, is_active, description, icon_url
|
||||
`
|
||||
|
||||
type UpdateApiServiceParams struct {
|
||||
@ -214,6 +218,7 @@ func (q *Queries) UpdateApiService(ctx context.Context, arg UpdateApiServicePara
|
||||
&i.UpdatedAt,
|
||||
&i.IsActive,
|
||||
&i.Description,
|
||||
&i.IconUrl,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
@ -22,6 +22,44 @@ type ApiService struct {
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
IsActive bool `json:"is_active"`
|
||||
Description *string `json:"description"`
|
||||
IconUrl *string `json:"icon_url"`
|
||||
}
|
||||
|
||||
type Permission struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
Description *string `json:"description"`
|
||||
}
|
||||
|
||||
type Role struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
Description *string `json:"description"`
|
||||
}
|
||||
|
||||
type RolePermission struct {
|
||||
RoleID uuid.UUID `json:"role_id"`
|
||||
PermissionID uuid.UUID `json:"permission_id"`
|
||||
}
|
||||
|
||||
type ServiceSession struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ServiceID uuid.UUID `json:"service_id"`
|
||||
ClientID string `json:"client_id"`
|
||||
UserID *uuid.UUID `json:"user_id"`
|
||||
IssuedAt time.Time `json:"issued_at"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
LastActive *time.Time `json:"last_active"`
|
||||
IpAddress *string `json:"ip_address"`
|
||||
UserAgent *string `json:"user_agent"`
|
||||
AccessTokenID *uuid.UUID `json:"access_token_id"`
|
||||
RefreshTokenID *uuid.UUID `json:"refresh_token_id"`
|
||||
IsActive bool `json:"is_active"`
|
||||
RevokedAt *time.Time `json:"revoked_at"`
|
||||
Scope *string `json:"scope"`
|
||||
Claims []byte `json:"claims"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
@ -37,4 +75,32 @@ type User struct {
|
||||
ProfilePicture *string `json:"profile_picture"`
|
||||
CreatedBy *uuid.UUID `json:"created_by"`
|
||||
EmailVerified bool `json:"email_verified"`
|
||||
AvatarVerified bool `json:"avatar_verified"`
|
||||
Verified bool `json:"verified"`
|
||||
}
|
||||
|
||||
type UserPermission struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
PermissionID uuid.UUID `json:"permission_id"`
|
||||
}
|
||||
|
||||
type UserRole struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
RoleID uuid.UUID `json:"role_id"`
|
||||
}
|
||||
|
||||
type UserSession struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
SessionType string `json:"session_type"`
|
||||
IssuedAt time.Time `json:"issued_at"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
LastActive *time.Time `json:"last_active"`
|
||||
IpAddress *string `json:"ip_address"`
|
||||
UserAgent *string `json:"user_agent"`
|
||||
AccessTokenID *uuid.UUID `json:"access_token_id"`
|
||||
RefreshTokenID *uuid.UUID `json:"refresh_token_id"`
|
||||
DeviceInfo []byte `json:"device_info"`
|
||||
IsActive bool `json:"is_active"`
|
||||
RevokedAt *time.Time `json:"revoked_at"`
|
||||
}
|
||||
|
156
internal/repository/permissions.sql.go
Normal file
156
internal/repository/permissions.sql.go
Normal file
@ -0,0 +1,156 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
// source: permissions.sql
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const createPermission = `-- name: CreatePermission :one
|
||||
INSERT into permissions (
|
||||
name, scope, description
|
||||
) VALUES (
|
||||
$1, $2, $3
|
||||
) RETURNING id, name, scope, description
|
||||
`
|
||||
|
||||
type CreatePermissionParams struct {
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
Description *string `json:"description"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreatePermission(ctx context.Context, arg CreatePermissionParams) (Permission, error) {
|
||||
row := q.db.QueryRow(ctx, createPermission, arg.Name, arg.Scope, arg.Description)
|
||||
var i Permission
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.Scope,
|
||||
&i.Description,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const findPermission = `-- name: FindPermission :one
|
||||
SELECT id, name, scope, description FROM permissions
|
||||
WHERE name = $1 AND scope = $2
|
||||
`
|
||||
|
||||
type FindPermissionParams struct {
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
func (q *Queries) FindPermission(ctx context.Context, arg FindPermissionParams) (Permission, error) {
|
||||
row := q.db.QueryRow(ctx, findPermission, arg.Name, arg.Scope)
|
||||
var i Permission
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.Scope,
|
||||
&i.Description,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getAllPermissions = `-- name: GetAllPermissions :many
|
||||
SELECT id, name, scope, description
|
||||
FROM permissions p
|
||||
ORDER BY p.scope
|
||||
`
|
||||
|
||||
func (q *Queries) GetAllPermissions(ctx context.Context) ([]Permission, error) {
|
||||
rows, err := q.db.Query(ctx, getAllPermissions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Permission
|
||||
for rows.Next() {
|
||||
var i Permission
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.Scope,
|
||||
&i.Description,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getGroupedPermissions = `-- name: GetGroupedPermissions :many
|
||||
SELECT scope, json_agg(to_jsonb(permissions.*) ORDER BY name) as permissions
|
||||
FROM permissions
|
||||
GROUP BY scope
|
||||
`
|
||||
|
||||
type GetGroupedPermissionsRow struct {
|
||||
Scope string `json:"scope"`
|
||||
Permissions []byte `json:"permissions"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetGroupedPermissions(ctx context.Context) ([]GetGroupedPermissionsRow, error) {
|
||||
rows, err := q.db.Query(ctx, getGroupedPermissions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetGroupedPermissionsRow
|
||||
for rows.Next() {
|
||||
var i GetGroupedPermissionsRow
|
||||
if err := rows.Scan(&i.Scope, &i.Permissions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getUserPermissions = `-- name: GetUserPermissions :many
|
||||
SELECT DISTINCT p.id,p.name,p.scope,p.description
|
||||
FROM user_roles ur
|
||||
JOIN role_permissions rp ON ur.role_id = rp.role_id
|
||||
JOIN permissions p ON rp.permission_id = p.id
|
||||
WHERE ur.user_id = $1
|
||||
ORDER BY p.scope
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserPermissions(ctx context.Context, userID uuid.UUID) ([]Permission, error) {
|
||||
rows, err := q.db.Query(ctx, getUserPermissions, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Permission
|
||||
for rows.Next() {
|
||||
var i Permission
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.Scope,
|
||||
&i.Description,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
282
internal/repository/roles.sql.go
Normal file
282
internal/repository/roles.sql.go
Normal file
@ -0,0 +1,282 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
// source: roles.sql
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const addPermissionsToRoleByKey = `-- name: AddPermissionsToRoleByKey :exec
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
SELECT
|
||||
$1,
|
||||
p.id
|
||||
FROM
|
||||
permissions p
|
||||
JOIN
|
||||
unnest($2::text[]) AS key_str
|
||||
ON key_str = p.scope || '_' || p.name
|
||||
`
|
||||
|
||||
type AddPermissionsToRoleByKeyParams struct {
|
||||
RoleID uuid.UUID `json:"role_id"`
|
||||
PermissionKeys []string `json:"permission_keys"`
|
||||
}
|
||||
|
||||
func (q *Queries) AddPermissionsToRoleByKey(ctx context.Context, arg AddPermissionsToRoleByKeyParams) error {
|
||||
_, err := q.db.Exec(ctx, addPermissionsToRoleByKey, arg.RoleID, arg.PermissionKeys)
|
||||
return err
|
||||
}
|
||||
|
||||
const assignRolePermission = `-- name: AssignRolePermission :exec
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
VALUES (
|
||||
$1,
|
||||
(
|
||||
SELECT id
|
||||
FROM permissions p
|
||||
WHERE p.scope = split_part($2, '_', 1)
|
||||
AND p.name = right($2, length($2) - position('_' IN $2))
|
||||
)
|
||||
)
|
||||
`
|
||||
|
||||
type AssignRolePermissionParams struct {
|
||||
RoleID uuid.UUID `json:"role_id"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
func (q *Queries) AssignRolePermission(ctx context.Context, arg AssignRolePermissionParams) error {
|
||||
_, err := q.db.Exec(ctx, assignRolePermission, arg.RoleID, arg.Key)
|
||||
return err
|
||||
}
|
||||
|
||||
const assignUserRole = `-- name: AssignUserRole :exec
|
||||
INSERT INTO user_roles (user_id, role_id)
|
||||
VALUES ($1, (
|
||||
SELECT id FROM roles r
|
||||
WHERE r.scope = split_part($2, '_', 1)
|
||||
AND r.name = right($2, length($2) - position('_' IN $2))
|
||||
))
|
||||
`
|
||||
|
||||
type AssignUserRoleParams struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
func (q *Queries) AssignUserRole(ctx context.Context, arg AssignUserRoleParams) error {
|
||||
_, err := q.db.Exec(ctx, assignUserRole, arg.UserID, arg.Key)
|
||||
return err
|
||||
}
|
||||
|
||||
const createRole = `-- name: CreateRole :one
|
||||
INSERT INTO roles (name, scope, description)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, name, scope, description
|
||||
`
|
||||
|
||||
type CreateRoleParams struct {
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
Description *string `json:"description"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateRole(ctx context.Context, arg CreateRoleParams) (Role, error) {
|
||||
row := q.db.QueryRow(ctx, createRole, arg.Name, arg.Scope, arg.Description)
|
||||
var i Role
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.Scope,
|
||||
&i.Description,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const findRole = `-- name: FindRole :one
|
||||
SELECT id, name, scope, description
|
||||
FROM roles
|
||||
WHERE scope = $1 AND name = $2
|
||||
`
|
||||
|
||||
type FindRoleParams struct {
|
||||
Scope string `json:"scope"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (q *Queries) FindRole(ctx context.Context, arg FindRoleParams) (Role, error) {
|
||||
row := q.db.QueryRow(ctx, findRole, arg.Scope, arg.Name)
|
||||
var i Role
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.Scope,
|
||||
&i.Description,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const findUserRole = `-- name: FindUserRole :one
|
||||
SELECT user_id, role_id FROM user_roles
|
||||
WHERE user_id = $1 AND role_id = (SELECT id FROM roles r WHERE r.scope = split_part($2, '_', 1) AND r.name = right($2, length($2) - position('_' IN $2)))
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
type FindUserRoleParams struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
func (q *Queries) FindUserRole(ctx context.Context, arg FindUserRoleParams) (UserRole, error) {
|
||||
row := q.db.QueryRow(ctx, findUserRole, arg.UserID, arg.Key)
|
||||
var i UserRole
|
||||
err := row.Scan(&i.UserID, &i.RoleID)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getRoleAssignment = `-- name: GetRoleAssignment :one
|
||||
SELECT role_id, permission_id FROM role_permissions
|
||||
WHERE role_id = $1 AND permission_id = (SELECT id FROM permissions p WHERE p.scope = split_part($2, '_', 1) AND p.name = right($2, length($2) - position('_' IN $2)))
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
type GetRoleAssignmentParams struct {
|
||||
RoleID uuid.UUID `json:"role_id"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetRoleAssignment(ctx context.Context, arg GetRoleAssignmentParams) (RolePermission, error) {
|
||||
row := q.db.QueryRow(ctx, getRoleAssignment, arg.RoleID, arg.Key)
|
||||
var i RolePermission
|
||||
err := row.Scan(&i.RoleID, &i.PermissionID)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getRolesGroupedWithPermissions = `-- name: GetRolesGroupedWithPermissions :many
|
||||
SELECT
|
||||
r.scope,
|
||||
json_agg (
|
||||
json_build_object (
|
||||
'id',
|
||||
r.id,
|
||||
'name',
|
||||
r.name,
|
||||
'scope',
|
||||
r.scope,
|
||||
'description',
|
||||
r.description,
|
||||
'permissions',
|
||||
COALESCE(p_list.permissions, '[]')
|
||||
)
|
||||
ORDER BY
|
||||
r.name
|
||||
) AS roles
|
||||
FROM
|
||||
roles r
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
rp.role_id,
|
||||
json_agg (
|
||||
json_build_object (
|
||||
'id',
|
||||
p.id,
|
||||
'name',
|
||||
p.name,
|
||||
'scope',
|
||||
p.scope,
|
||||
'description',
|
||||
p.description
|
||||
)
|
||||
ORDER BY
|
||||
p.name
|
||||
) AS permissions
|
||||
FROM
|
||||
role_permissions rp
|
||||
JOIN permissions p ON p.id = rp.permission_id
|
||||
GROUP BY
|
||||
rp.role_id
|
||||
) p_list ON p_list.role_id = r.id
|
||||
GROUP BY
|
||||
r.scope
|
||||
`
|
||||
|
||||
type GetRolesGroupedWithPermissionsRow struct {
|
||||
Scope string `json:"scope"`
|
||||
Roles []byte `json:"roles"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetRolesGroupedWithPermissions(ctx context.Context) ([]GetRolesGroupedWithPermissionsRow, error) {
|
||||
rows, err := q.db.Query(ctx, getRolesGroupedWithPermissions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetRolesGroupedWithPermissionsRow
|
||||
for rows.Next() {
|
||||
var i GetRolesGroupedWithPermissionsRow
|
||||
if err := rows.Scan(&i.Scope, &i.Roles); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getUserRoles = `-- name: GetUserRoles :many
|
||||
SELECT r.id, r.name, r.scope, r.description FROM roles r
|
||||
JOIN user_roles ur ON r.id = ur.role_id
|
||||
WHERE ur.user_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserRoles(ctx context.Context, userID uuid.UUID) ([]Role, error) {
|
||||
rows, err := q.db.Query(ctx, getUserRoles, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Role
|
||||
for rows.Next() {
|
||||
var i Role
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.Scope,
|
||||
&i.Description,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const unassignUserRole = `-- name: UnassignUserRole :exec
|
||||
DELETE FROM user_roles
|
||||
WHERE user_id = $1 AND role_id = (
|
||||
SELECT id FROM roles r
|
||||
WHERE r.scope = split_part($2, '_', 1)
|
||||
AND r.name = right($2, length($2) - position('_' IN $2))
|
||||
)
|
||||
`
|
||||
|
||||
type UnassignUserRoleParams struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
func (q *Queries) UnassignUserRole(ctx context.Context, arg UnassignUserRoleParams) error {
|
||||
_, err := q.db.Exec(ctx, unassignUserRole, arg.UserID, arg.Key)
|
||||
return err
|
||||
}
|
419
internal/repository/service_sessions.sql.go
Normal file
419
internal/repository/service_sessions.sql.go
Normal file
@ -0,0 +1,419 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
// source: service_sessions.sql
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const createServiceSession = `-- name: CreateServiceSession :one
|
||||
INSERT INTO service_sessions (
|
||||
service_id, client_id, user_id, issued_at, expires_at, last_active,
|
||||
ip_address, user_agent, access_token_id, refresh_token_id,
|
||||
is_active, scope, claims
|
||||
) VALUES (
|
||||
$1, $2, $3, NOW(), $4, $5,
|
||||
$6, $7, $8, $9,
|
||||
TRUE, $10, $11
|
||||
)
|
||||
RETURNING id, service_id, client_id, user_id, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, is_active, revoked_at, scope, claims
|
||||
`
|
||||
|
||||
type CreateServiceSessionParams struct {
|
||||
ServiceID uuid.UUID `json:"service_id"`
|
||||
ClientID string `json:"client_id"`
|
||||
UserID *uuid.UUID `json:"user_id"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
LastActive *time.Time `json:"last_active"`
|
||||
IpAddress *string `json:"ip_address"`
|
||||
UserAgent *string `json:"user_agent"`
|
||||
AccessTokenID *uuid.UUID `json:"access_token_id"`
|
||||
RefreshTokenID *uuid.UUID `json:"refresh_token_id"`
|
||||
Scope *string `json:"scope"`
|
||||
Claims []byte `json:"claims"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateServiceSession(ctx context.Context, arg CreateServiceSessionParams) (ServiceSession, error) {
|
||||
row := q.db.QueryRow(ctx, createServiceSession,
|
||||
arg.ServiceID,
|
||||
arg.ClientID,
|
||||
arg.UserID,
|
||||
arg.ExpiresAt,
|
||||
arg.LastActive,
|
||||
arg.IpAddress,
|
||||
arg.UserAgent,
|
||||
arg.AccessTokenID,
|
||||
arg.RefreshTokenID,
|
||||
arg.Scope,
|
||||
arg.Claims,
|
||||
)
|
||||
var i ServiceSession
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ServiceID,
|
||||
&i.ClientID,
|
||||
&i.UserID,
|
||||
&i.IssuedAt,
|
||||
&i.ExpiresAt,
|
||||
&i.LastActive,
|
||||
&i.IpAddress,
|
||||
&i.UserAgent,
|
||||
&i.AccessTokenID,
|
||||
&i.RefreshTokenID,
|
||||
&i.IsActive,
|
||||
&i.RevokedAt,
|
||||
&i.Scope,
|
||||
&i.Claims,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getServiceSessionByAccessJTI = `-- name: GetServiceSessionByAccessJTI :one
|
||||
SELECT id, service_id, client_id, user_id, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, is_active, revoked_at, scope, claims FROM service_sessions
|
||||
WHERE access_token_id = $1
|
||||
AND is_active = TRUE
|
||||
`
|
||||
|
||||
func (q *Queries) GetServiceSessionByAccessJTI(ctx context.Context, accessTokenID *uuid.UUID) (ServiceSession, error) {
|
||||
row := q.db.QueryRow(ctx, getServiceSessionByAccessJTI, accessTokenID)
|
||||
var i ServiceSession
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ServiceID,
|
||||
&i.ClientID,
|
||||
&i.UserID,
|
||||
&i.IssuedAt,
|
||||
&i.ExpiresAt,
|
||||
&i.LastActive,
|
||||
&i.IpAddress,
|
||||
&i.UserAgent,
|
||||
&i.AccessTokenID,
|
||||
&i.RefreshTokenID,
|
||||
&i.IsActive,
|
||||
&i.RevokedAt,
|
||||
&i.Scope,
|
||||
&i.Claims,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getServiceSessionByRefreshJTI = `-- name: GetServiceSessionByRefreshJTI :one
|
||||
SELECT id, service_id, client_id, user_id, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, is_active, revoked_at, scope, claims FROM service_sessions
|
||||
WHERE refresh_token_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetServiceSessionByRefreshJTI(ctx context.Context, refreshTokenID *uuid.UUID) (ServiceSession, error) {
|
||||
row := q.db.QueryRow(ctx, getServiceSessionByRefreshJTI, refreshTokenID)
|
||||
var i ServiceSession
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ServiceID,
|
||||
&i.ClientID,
|
||||
&i.UserID,
|
||||
&i.IssuedAt,
|
||||
&i.ExpiresAt,
|
||||
&i.LastActive,
|
||||
&i.IpAddress,
|
||||
&i.UserAgent,
|
||||
&i.AccessTokenID,
|
||||
&i.RefreshTokenID,
|
||||
&i.IsActive,
|
||||
&i.RevokedAt,
|
||||
&i.Scope,
|
||||
&i.Claims,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getServiceSessions = `-- name: GetServiceSessions :many
|
||||
SELECT session.id, session.service_id, session.client_id, session.user_id, session.issued_at, session.expires_at, session.last_active, session.ip_address, session.user_agent, session.access_token_id, session.refresh_token_id, session.is_active, session.revoked_at, session.scope, session.claims, service.id, service.client_id, service.client_secret, service.name, service.redirect_uris, service.scopes, service.grant_types, service.created_at, service.updated_at, service.is_active, service.description, service.icon_url, u.id, u.email, u.full_name, u.password_hash, u.is_admin, u.created_at, u.updated_at, u.last_login, u.phone_number, u.profile_picture, u.created_by, u.email_verified, u.avatar_verified, u.verified
|
||||
FROM service_sessions AS session
|
||||
JOIN api_services AS service ON service.id = session.service_id
|
||||
JOIN users AS u ON u.id = session.user_id
|
||||
ORDER BY session.issued_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`
|
||||
|
||||
type GetServiceSessionsParams struct {
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
}
|
||||
|
||||
type GetServiceSessionsRow struct {
|
||||
ServiceSession ServiceSession `json:"service_session"`
|
||||
ApiService ApiService `json:"api_service"`
|
||||
User User `json:"user"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetServiceSessions(ctx context.Context, arg GetServiceSessionsParams) ([]GetServiceSessionsRow, error) {
|
||||
rows, err := q.db.Query(ctx, getServiceSessions, arg.Limit, arg.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetServiceSessionsRow
|
||||
for rows.Next() {
|
||||
var i GetServiceSessionsRow
|
||||
if err := rows.Scan(
|
||||
&i.ServiceSession.ID,
|
||||
&i.ServiceSession.ServiceID,
|
||||
&i.ServiceSession.ClientID,
|
||||
&i.ServiceSession.UserID,
|
||||
&i.ServiceSession.IssuedAt,
|
||||
&i.ServiceSession.ExpiresAt,
|
||||
&i.ServiceSession.LastActive,
|
||||
&i.ServiceSession.IpAddress,
|
||||
&i.ServiceSession.UserAgent,
|
||||
&i.ServiceSession.AccessTokenID,
|
||||
&i.ServiceSession.RefreshTokenID,
|
||||
&i.ServiceSession.IsActive,
|
||||
&i.ServiceSession.RevokedAt,
|
||||
&i.ServiceSession.Scope,
|
||||
&i.ServiceSession.Claims,
|
||||
&i.ApiService.ID,
|
||||
&i.ApiService.ClientID,
|
||||
&i.ApiService.ClientSecret,
|
||||
&i.ApiService.Name,
|
||||
&i.ApiService.RedirectUris,
|
||||
&i.ApiService.Scopes,
|
||||
&i.ApiService.GrantTypes,
|
||||
&i.ApiService.CreatedAt,
|
||||
&i.ApiService.UpdatedAt,
|
||||
&i.ApiService.IsActive,
|
||||
&i.ApiService.Description,
|
||||
&i.ApiService.IconUrl,
|
||||
&i.User.ID,
|
||||
&i.User.Email,
|
||||
&i.User.FullName,
|
||||
&i.User.PasswordHash,
|
||||
&i.User.IsAdmin,
|
||||
&i.User.CreatedAt,
|
||||
&i.User.UpdatedAt,
|
||||
&i.User.LastLogin,
|
||||
&i.User.PhoneNumber,
|
||||
&i.User.ProfilePicture,
|
||||
&i.User.CreatedBy,
|
||||
&i.User.EmailVerified,
|
||||
&i.User.AvatarVerified,
|
||||
&i.User.Verified,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getServiceSessionsCount = `-- name: GetServiceSessionsCount :one
|
||||
SELECT COUNT(*) FROM service_sessions
|
||||
`
|
||||
|
||||
func (q *Queries) GetServiceSessionsCount(ctx context.Context) (int64, error) {
|
||||
row := q.db.QueryRow(ctx, getServiceSessionsCount)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const listActiveServiceSessionsByClient = `-- name: ListActiveServiceSessionsByClient :many
|
||||
SELECT id, service_id, client_id, user_id, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, is_active, revoked_at, scope, claims FROM service_sessions
|
||||
WHERE client_id = $1
|
||||
AND is_active = TRUE
|
||||
ORDER BY issued_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`
|
||||
|
||||
type ListActiveServiceSessionsByClientParams struct {
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListActiveServiceSessionsByClient(ctx context.Context, arg ListActiveServiceSessionsByClientParams) ([]ServiceSession, error) {
|
||||
rows, err := q.db.Query(ctx, listActiveServiceSessionsByClient, arg.Limit, arg.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ServiceSession
|
||||
for rows.Next() {
|
||||
var i ServiceSession
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ServiceID,
|
||||
&i.ClientID,
|
||||
&i.UserID,
|
||||
&i.IssuedAt,
|
||||
&i.ExpiresAt,
|
||||
&i.LastActive,
|
||||
&i.IpAddress,
|
||||
&i.UserAgent,
|
||||
&i.AccessTokenID,
|
||||
&i.RefreshTokenID,
|
||||
&i.IsActive,
|
||||
&i.RevokedAt,
|
||||
&i.Scope,
|
||||
&i.Claims,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listActiveServiceSessionsByUser = `-- name: ListActiveServiceSessionsByUser :many
|
||||
SELECT id, service_id, client_id, user_id, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, is_active, revoked_at, scope, claims FROM service_sessions
|
||||
WHERE user_id = $1
|
||||
AND is_active = TRUE
|
||||
ORDER BY issued_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`
|
||||
|
||||
type ListActiveServiceSessionsByUserParams struct {
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListActiveServiceSessionsByUser(ctx context.Context, arg ListActiveServiceSessionsByUserParams) ([]ServiceSession, error) {
|
||||
rows, err := q.db.Query(ctx, listActiveServiceSessionsByUser, arg.Limit, arg.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ServiceSession
|
||||
for rows.Next() {
|
||||
var i ServiceSession
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ServiceID,
|
||||
&i.ClientID,
|
||||
&i.UserID,
|
||||
&i.IssuedAt,
|
||||
&i.ExpiresAt,
|
||||
&i.LastActive,
|
||||
&i.IpAddress,
|
||||
&i.UserAgent,
|
||||
&i.AccessTokenID,
|
||||
&i.RefreshTokenID,
|
||||
&i.IsActive,
|
||||
&i.RevokedAt,
|
||||
&i.Scope,
|
||||
&i.Claims,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listAllServiceSessions = `-- name: ListAllServiceSessions :many
|
||||
SELECT id, service_id, client_id, user_id, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, is_active, revoked_at, scope, claims FROM service_sessions
|
||||
ORDER BY issued_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`
|
||||
|
||||
type ListAllServiceSessionsParams struct {
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListAllServiceSessions(ctx context.Context, arg ListAllServiceSessionsParams) ([]ServiceSession, error) {
|
||||
rows, err := q.db.Query(ctx, listAllServiceSessions, arg.Limit, arg.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ServiceSession
|
||||
for rows.Next() {
|
||||
var i ServiceSession
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ServiceID,
|
||||
&i.ClientID,
|
||||
&i.UserID,
|
||||
&i.IssuedAt,
|
||||
&i.ExpiresAt,
|
||||
&i.LastActive,
|
||||
&i.IpAddress,
|
||||
&i.UserAgent,
|
||||
&i.AccessTokenID,
|
||||
&i.RefreshTokenID,
|
||||
&i.IsActive,
|
||||
&i.RevokedAt,
|
||||
&i.Scope,
|
||||
&i.Claims,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const revokeServiceSession = `-- name: RevokeServiceSession :exec
|
||||
UPDATE service_sessions
|
||||
SET is_active = FALSE,
|
||||
revoked_at = NOW()
|
||||
WHERE id = $1
|
||||
AND is_active = TRUE
|
||||
`
|
||||
|
||||
func (q *Queries) RevokeServiceSession(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := q.db.Exec(ctx, revokeServiceSession, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateServiceSessionLastActive = `-- name: UpdateServiceSessionLastActive :exec
|
||||
UPDATE service_sessions
|
||||
SET last_active = NOW()
|
||||
WHERE id = $1
|
||||
AND is_active = TRUE
|
||||
`
|
||||
|
||||
func (q *Queries) UpdateServiceSessionLastActive(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := q.db.Exec(ctx, updateServiceSessionLastActive, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateServiceSessionTokens = `-- name: UpdateServiceSessionTokens :exec
|
||||
UPDATE service_sessions
|
||||
SET access_token_id = $2, refresh_token_id = $3, expires_at = $4
|
||||
WHERE id = $1
|
||||
AND is_active = TRUE
|
||||
`
|
||||
|
||||
type UpdateServiceSessionTokensParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
AccessTokenID *uuid.UUID `json:"access_token_id"`
|
||||
RefreshTokenID *uuid.UUID `json:"refresh_token_id"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateServiceSessionTokens(ctx context.Context, arg UpdateServiceSessionTokensParams) error {
|
||||
_, err := q.db.Exec(ctx, updateServiceSessionTokens,
|
||||
arg.ID,
|
||||
arg.AccessTokenID,
|
||||
arg.RefreshTokenID,
|
||||
arg.ExpiresAt,
|
||||
)
|
||||
return err
|
||||
}
|
334
internal/repository/user_sessions.sql.go
Normal file
334
internal/repository/user_sessions.sql.go
Normal file
@ -0,0 +1,334 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
// source: user_sessions.sql
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const createUserSession = `-- name: CreateUserSession :one
|
||||
INSERT INTO user_sessions (
|
||||
user_id, session_type, issued_at, expires_at, last_active,
|
||||
ip_address, user_agent, access_token_id, refresh_token_id,
|
||||
device_info, is_active
|
||||
) VALUES (
|
||||
$1, $2, NOW(), $3, $4,
|
||||
$5, $6, $7, $8,
|
||||
$9, TRUE
|
||||
)
|
||||
RETURNING id, user_id, session_type, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, device_info, is_active, revoked_at
|
||||
`
|
||||
|
||||
type CreateUserSessionParams struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
SessionType string `json:"session_type"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
LastActive *time.Time `json:"last_active"`
|
||||
IpAddress *string `json:"ip_address"`
|
||||
UserAgent *string `json:"user_agent"`
|
||||
AccessTokenID *uuid.UUID `json:"access_token_id"`
|
||||
RefreshTokenID *uuid.UUID `json:"refresh_token_id"`
|
||||
DeviceInfo []byte `json:"device_info"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateUserSession(ctx context.Context, arg CreateUserSessionParams) (UserSession, error) {
|
||||
row := q.db.QueryRow(ctx, createUserSession,
|
||||
arg.UserID,
|
||||
arg.SessionType,
|
||||
arg.ExpiresAt,
|
||||
arg.LastActive,
|
||||
arg.IpAddress,
|
||||
arg.UserAgent,
|
||||
arg.AccessTokenID,
|
||||
arg.RefreshTokenID,
|
||||
arg.DeviceInfo,
|
||||
)
|
||||
var i UserSession
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.SessionType,
|
||||
&i.IssuedAt,
|
||||
&i.ExpiresAt,
|
||||
&i.LastActive,
|
||||
&i.IpAddress,
|
||||
&i.UserAgent,
|
||||
&i.AccessTokenID,
|
||||
&i.RefreshTokenID,
|
||||
&i.DeviceInfo,
|
||||
&i.IsActive,
|
||||
&i.RevokedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserSessionByAccessJTI = `-- name: GetUserSessionByAccessJTI :one
|
||||
SELECT id, user_id, session_type, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, device_info, is_active, revoked_at FROM user_sessions
|
||||
WHERE access_token_id = $1
|
||||
AND is_active = TRUE
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserSessionByAccessJTI(ctx context.Context, accessTokenID *uuid.UUID) (UserSession, error) {
|
||||
row := q.db.QueryRow(ctx, getUserSessionByAccessJTI, accessTokenID)
|
||||
var i UserSession
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.SessionType,
|
||||
&i.IssuedAt,
|
||||
&i.ExpiresAt,
|
||||
&i.LastActive,
|
||||
&i.IpAddress,
|
||||
&i.UserAgent,
|
||||
&i.AccessTokenID,
|
||||
&i.RefreshTokenID,
|
||||
&i.DeviceInfo,
|
||||
&i.IsActive,
|
||||
&i.RevokedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserSessionByRefreshJTI = `-- name: GetUserSessionByRefreshJTI :one
|
||||
SELECT id, user_id, session_type, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, device_info, is_active, revoked_at FROM user_sessions
|
||||
WHERE refresh_token_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserSessionByRefreshJTI(ctx context.Context, refreshTokenID *uuid.UUID) (UserSession, error) {
|
||||
row := q.db.QueryRow(ctx, getUserSessionByRefreshJTI, refreshTokenID)
|
||||
var i UserSession
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.SessionType,
|
||||
&i.IssuedAt,
|
||||
&i.ExpiresAt,
|
||||
&i.LastActive,
|
||||
&i.IpAddress,
|
||||
&i.UserAgent,
|
||||
&i.AccessTokenID,
|
||||
&i.RefreshTokenID,
|
||||
&i.DeviceInfo,
|
||||
&i.IsActive,
|
||||
&i.RevokedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserSessions = `-- name: GetUserSessions :many
|
||||
SELECT session.id, session.user_id, session.session_type, session.issued_at, session.expires_at, session.last_active, session.ip_address, session.user_agent, session.access_token_id, session.refresh_token_id, session.device_info, session.is_active, session.revoked_at, u.id, u.email, u.full_name, u.password_hash, u.is_admin, u.created_at, u.updated_at, u.last_login, u.phone_number, u.profile_picture, u.created_by, u.email_verified, u.avatar_verified, u.verified
|
||||
FROM user_sessions AS session
|
||||
JOIN users AS u ON u.id = session.user_id
|
||||
ORDER BY session.issued_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`
|
||||
|
||||
type GetUserSessionsParams struct {
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
}
|
||||
|
||||
type GetUserSessionsRow struct {
|
||||
UserSession UserSession `json:"user_session"`
|
||||
User User `json:"user"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetUserSessions(ctx context.Context, arg GetUserSessionsParams) ([]GetUserSessionsRow, error) {
|
||||
rows, err := q.db.Query(ctx, getUserSessions, arg.Limit, arg.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetUserSessionsRow
|
||||
for rows.Next() {
|
||||
var i GetUserSessionsRow
|
||||
if err := rows.Scan(
|
||||
&i.UserSession.ID,
|
||||
&i.UserSession.UserID,
|
||||
&i.UserSession.SessionType,
|
||||
&i.UserSession.IssuedAt,
|
||||
&i.UserSession.ExpiresAt,
|
||||
&i.UserSession.LastActive,
|
||||
&i.UserSession.IpAddress,
|
||||
&i.UserSession.UserAgent,
|
||||
&i.UserSession.AccessTokenID,
|
||||
&i.UserSession.RefreshTokenID,
|
||||
&i.UserSession.DeviceInfo,
|
||||
&i.UserSession.IsActive,
|
||||
&i.UserSession.RevokedAt,
|
||||
&i.User.ID,
|
||||
&i.User.Email,
|
||||
&i.User.FullName,
|
||||
&i.User.PasswordHash,
|
||||
&i.User.IsAdmin,
|
||||
&i.User.CreatedAt,
|
||||
&i.User.UpdatedAt,
|
||||
&i.User.LastLogin,
|
||||
&i.User.PhoneNumber,
|
||||
&i.User.ProfilePicture,
|
||||
&i.User.CreatedBy,
|
||||
&i.User.EmailVerified,
|
||||
&i.User.AvatarVerified,
|
||||
&i.User.Verified,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getUserSessionsCount = `-- name: GetUserSessionsCount :one
|
||||
SELECT COUNT(*) FROM user_sessions
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserSessionsCount(ctx context.Context) (int64, error) {
|
||||
row := q.db.QueryRow(ctx, getUserSessionsCount)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const listActiveUserSessions = `-- name: ListActiveUserSessions :many
|
||||
SELECT id, user_id, session_type, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, device_info, is_active, revoked_at FROM user_sessions
|
||||
WHERE user_id = $1
|
||||
AND is_active = TRUE
|
||||
ORDER BY issued_at DESC
|
||||
`
|
||||
|
||||
func (q *Queries) ListActiveUserSessions(ctx context.Context, userID uuid.UUID) ([]UserSession, error) {
|
||||
rows, err := q.db.Query(ctx, listActiveUserSessions, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []UserSession
|
||||
for rows.Next() {
|
||||
var i UserSession
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.SessionType,
|
||||
&i.IssuedAt,
|
||||
&i.ExpiresAt,
|
||||
&i.LastActive,
|
||||
&i.IpAddress,
|
||||
&i.UserAgent,
|
||||
&i.AccessTokenID,
|
||||
&i.RefreshTokenID,
|
||||
&i.DeviceInfo,
|
||||
&i.IsActive,
|
||||
&i.RevokedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listAllSessions = `-- name: ListAllSessions :many
|
||||
SELECT id, user_id, session_type, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, device_info, is_active, revoked_at FROM user_sessions
|
||||
ORDER BY issued_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`
|
||||
|
||||
type ListAllSessionsParams struct {
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListAllSessions(ctx context.Context, arg ListAllSessionsParams) ([]UserSession, error) {
|
||||
rows, err := q.db.Query(ctx, listAllSessions, arg.Limit, arg.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []UserSession
|
||||
for rows.Next() {
|
||||
var i UserSession
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.SessionType,
|
||||
&i.IssuedAt,
|
||||
&i.ExpiresAt,
|
||||
&i.LastActive,
|
||||
&i.IpAddress,
|
||||
&i.UserAgent,
|
||||
&i.AccessTokenID,
|
||||
&i.RefreshTokenID,
|
||||
&i.DeviceInfo,
|
||||
&i.IsActive,
|
||||
&i.RevokedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const revokeUserSession = `-- name: RevokeUserSession :exec
|
||||
UPDATE user_sessions
|
||||
SET is_active = FALSE,
|
||||
revoked_at = NOW()
|
||||
WHERE id = $1
|
||||
AND is_active = TRUE
|
||||
`
|
||||
|
||||
func (q *Queries) RevokeUserSession(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := q.db.Exec(ctx, revokeUserSession, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateSessionLastActive = `-- name: UpdateSessionLastActive :exec
|
||||
UPDATE user_sessions
|
||||
SET last_active = NOW()
|
||||
WHERE id = $1
|
||||
AND is_active = TRUE
|
||||
`
|
||||
|
||||
func (q *Queries) UpdateSessionLastActive(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := q.db.Exec(ctx, updateSessionLastActive, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateSessionTokens = `-- name: UpdateSessionTokens :exec
|
||||
UPDATE user_sessions
|
||||
SET access_token_id = $2, refresh_token_id = $3, expires_at = $4
|
||||
WHERE id = $1
|
||||
AND is_active = TRUE
|
||||
`
|
||||
|
||||
type UpdateSessionTokensParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
AccessTokenID *uuid.UUID `json:"access_token_id"`
|
||||
RefreshTokenID *uuid.UUID `json:"refresh_token_id"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateSessionTokens(ctx context.Context, arg UpdateSessionTokensParams) error {
|
||||
_, err := q.db.Exec(ctx, updateSessionTokens,
|
||||
arg.ID,
|
||||
arg.AccessTokenID,
|
||||
arg.RefreshTokenID,
|
||||
arg.ExpiresAt,
|
||||
)
|
||||
return err
|
||||
}
|
@ -12,7 +12,7 @@ import (
|
||||
)
|
||||
|
||||
const findAdminUsers = `-- name: FindAdminUsers :many
|
||||
SELECT id, email, full_name, password_hash, is_admin, created_at, updated_at, last_login, phone_number, profile_picture, created_by, email_verified FROM users WHERE created_by = $1
|
||||
SELECT id, email, full_name, password_hash, is_admin, created_at, updated_at, last_login, phone_number, profile_picture, created_by, email_verified, avatar_verified, verified FROM users WHERE created_by = $1
|
||||
`
|
||||
|
||||
func (q *Queries) FindAdminUsers(ctx context.Context, createdBy *uuid.UUID) ([]User, error) {
|
||||
@ -37,6 +37,8 @@ func (q *Queries) FindAdminUsers(ctx context.Context, createdBy *uuid.UUID) ([]U
|
||||
&i.ProfilePicture,
|
||||
&i.CreatedBy,
|
||||
&i.EmailVerified,
|
||||
&i.AvatarVerified,
|
||||
&i.Verified,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -49,7 +51,7 @@ func (q *Queries) FindAdminUsers(ctx context.Context, createdBy *uuid.UUID) ([]U
|
||||
}
|
||||
|
||||
const findAllUsers = `-- name: FindAllUsers :many
|
||||
SELECT id, email, full_name, password_hash, is_admin, created_at, updated_at, last_login, phone_number, profile_picture, created_by, email_verified FROM users
|
||||
SELECT id, email, full_name, password_hash, is_admin, created_at, updated_at, last_login, phone_number, profile_picture, created_by, email_verified, avatar_verified, verified FROM users
|
||||
`
|
||||
|
||||
func (q *Queries) FindAllUsers(ctx context.Context) ([]User, error) {
|
||||
@ -74,6 +76,8 @@ func (q *Queries) FindAllUsers(ctx context.Context) ([]User, error) {
|
||||
&i.ProfilePicture,
|
||||
&i.CreatedBy,
|
||||
&i.EmailVerified,
|
||||
&i.AvatarVerified,
|
||||
&i.Verified,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -86,7 +90,7 @@ func (q *Queries) FindAllUsers(ctx context.Context) ([]User, error) {
|
||||
}
|
||||
|
||||
const findUserEmail = `-- name: FindUserEmail :one
|
||||
SELECT id, email, full_name, password_hash, is_admin, created_at, updated_at, last_login, phone_number, profile_picture, created_by, email_verified FROM users WHERE email = $1 LIMIT 1
|
||||
SELECT id, email, full_name, password_hash, is_admin, created_at, updated_at, last_login, phone_number, profile_picture, created_by, email_verified, avatar_verified, verified FROM users WHERE email = $1 LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) FindUserEmail(ctx context.Context, email string) (User, error) {
|
||||
@ -105,12 +109,14 @@ func (q *Queries) FindUserEmail(ctx context.Context, email string) (User, error)
|
||||
&i.ProfilePicture,
|
||||
&i.CreatedBy,
|
||||
&i.EmailVerified,
|
||||
&i.AvatarVerified,
|
||||
&i.Verified,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const findUserId = `-- name: FindUserId :one
|
||||
SELECT id, email, full_name, password_hash, is_admin, created_at, updated_at, last_login, phone_number, profile_picture, created_by, email_verified FROM users WHERE id = $1 LIMIT 1
|
||||
SELECT id, email, full_name, password_hash, is_admin, created_at, updated_at, last_login, phone_number, profile_picture, created_by, email_verified, avatar_verified, verified FROM users WHERE id = $1 LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) FindUserId(ctx context.Context, id uuid.UUID) (User, error) {
|
||||
@ -129,6 +135,8 @@ func (q *Queries) FindUserId(ctx context.Context, id uuid.UUID) (User, error) {
|
||||
&i.ProfilePicture,
|
||||
&i.CreatedBy,
|
||||
&i.EmailVerified,
|
||||
&i.AvatarVerified,
|
||||
&i.Verified,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@ -189,3 +197,36 @@ func (q *Queries) UpdateProfilePicture(ctx context.Context, arg UpdateProfilePic
|
||||
_, 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
|
||||
}
|
||||
|
38
internal/types/apiservices.go
Normal file
38
internal/types/apiservices.go
Normal file
@ -0,0 +1,38 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/repository"
|
||||
"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,
|
||||
}
|
||||
}
|
@ -8,13 +8,26 @@ type UserClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type ApiClaims struct {
|
||||
type IdTokenClaims struct {
|
||||
Email string `json:"email"`
|
||||
EmailVerified bool `json:"email_verified"`
|
||||
Name string `json:"name"`
|
||||
Picture *string `json:"picture"`
|
||||
Nonce string `json:"nonce"`
|
||||
Nonce *string `json:"nonce"`
|
||||
Roles []string `json:"roles"`
|
||||
// TODO: add given_name, family_name, locale...
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type ApiClaims struct {
|
||||
// FIXME: correct permissions
|
||||
Permissions []string `json:"permissions"`
|
||||
jwt.RegisteredClaims
|
||||
// Subject = ClientID
|
||||
}
|
||||
|
||||
type ApiRefreshClaims struct {
|
||||
UserID string `json:"user_id"`
|
||||
jwt.RegisteredClaims
|
||||
// Subject = ClientID
|
||||
}
|
||||
|
12
internal/types/device.go
Normal file
12
internal/types/device.go
Normal file
@ -0,0 +1,12 @@
|
||||
package types
|
||||
|
||||
type DeviceInfo struct {
|
||||
DeviceType string `json:"device_type"`
|
||||
OS string `json:"os"`
|
||||
OSVersion string `json:"os_version"`
|
||||
Browser string `json:"browser"`
|
||||
BrowserVersion string `json:"browser_version"`
|
||||
DeviceName string `json:"device_name"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
Location string `json:"location"`
|
||||
}
|
@ -3,4 +3,4 @@ package types
|
||||
type contextKey string
|
||||
|
||||
const UserIdKey contextKey = "userID"
|
||||
|
||||
const JTIKey contextKey = "jti"
|
||||
|
29
internal/types/session.go
Normal file
29
internal/types/session.go
Normal file
@ -0,0 +1,29 @@
|
||||
package types
|
||||
|
||||
import "gitea.local/admin/hspguard/internal/repository"
|
||||
|
||||
type ServiceSessionDTO struct {
|
||||
User UserDTO `json:"user"`
|
||||
ApiService ApiServiceDTO `json:"api_service"`
|
||||
repository.ServiceSession
|
||||
}
|
||||
|
||||
func NewServiceSessionDTO(row *repository.GetServiceSessionsRow) *ServiceSessionDTO {
|
||||
return &ServiceSessionDTO{
|
||||
User: NewUserDTO(&row.User),
|
||||
ApiService: NewApiServiceDTO(row.ApiService),
|
||||
ServiceSession: row.ServiceSession,
|
||||
}
|
||||
}
|
||||
|
||||
type UserSessionDTO struct {
|
||||
User UserDTO `json:"user"`
|
||||
repository.UserSession
|
||||
}
|
||||
|
||||
func NewUserSessionDTO(row *repository.GetUserSessionsRow) *UserSessionDTO {
|
||||
return &UserSessionDTO{
|
||||
User: NewUserDTO(&row.User),
|
||||
UserSession: row.UserSession,
|
||||
}
|
||||
}
|
21
internal/types/token.go
Normal file
21
internal/types/token.go
Normal file
@ -0,0 +1,21 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type SignedToken struct {
|
||||
Token string
|
||||
ExpiresAt time.Time
|
||||
ID uuid.UUID
|
||||
}
|
||||
|
||||
func NewSignedToken(token string, expiresAt time.Time, jti uuid.UUID) *SignedToken {
|
||||
return &SignedToken{
|
||||
Token: token,
|
||||
ExpiresAt: expiresAt,
|
||||
ID: jti,
|
||||
}
|
||||
}
|
@ -3,6 +3,7 @@ package types
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/repository"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
@ -16,4 +17,24 @@ type UserDTO struct {
|
||||
LastLogin *time.Time `json:"last_login"`
|
||||
PhoneNumber *string `json:"phone_number"`
|
||||
ProfilePicture *string `json:"profile_picture"`
|
||||
EmailVerified bool `json:"email_verified"`
|
||||
AvatarVerified bool `json:"avatar_verified"`
|
||||
Verified bool `json:"verified"`
|
||||
}
|
||||
|
||||
func NewUserDTO(row *repository.User) UserDTO {
|
||||
return UserDTO{
|
||||
ID: row.ID,
|
||||
Email: row.Email,
|
||||
FullName: row.FullName,
|
||||
IsAdmin: row.IsAdmin,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
LastLogin: row.LastLogin,
|
||||
PhoneNumber: row.PhoneNumber,
|
||||
ProfilePicture: row.ProfilePicture,
|
||||
EmailVerified: row.EmailVerified,
|
||||
AvatarVerified: row.AvatarVerified,
|
||||
Verified: row.Verified,
|
||||
}
|
||||
}
|
||||
|
215
internal/user/permissions.go
Normal file
215
internal/user/permissions.go
Normal file
@ -0,0 +1,215 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/repository"
|
||||
)
|
||||
|
||||
func String(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
type RolePermissions struct {
|
||||
Permissions []string `json:"permissions"`
|
||||
repository.Role
|
||||
}
|
||||
|
||||
var (
|
||||
SYSTEM_SCOPE string = "system"
|
||||
SYSTEM_PERMISSIONS []repository.Permission = []repository.Permission{
|
||||
{
|
||||
Name: "log_into_guard",
|
||||
Description: String("Allow users to log into their accounts"),
|
||||
},
|
||||
{
|
||||
Name: "register",
|
||||
Description: String("Allow users to register new accounts"),
|
||||
},
|
||||
{
|
||||
Name: "edit_profile",
|
||||
Description: String("Allow users to edit their profiles"),
|
||||
},
|
||||
{
|
||||
Name: "recover_credentials",
|
||||
Description: String("Allow users to recover their password/email"),
|
||||
},
|
||||
{
|
||||
Name: "verify_profile",
|
||||
Description: String("Allow users to verify their accounts"),
|
||||
},
|
||||
{
|
||||
Name: "access_home_services",
|
||||
Description: String("Allow users to access home services and tools"),
|
||||
},
|
||||
{
|
||||
Name: "view_sessions",
|
||||
Description: String("Allow users to view their active sessions"),
|
||||
},
|
||||
{
|
||||
Name: "revoke_sessions",
|
||||
Description: String("Allow users to revoke their active sessions"),
|
||||
},
|
||||
{
|
||||
Name: "view_api_services",
|
||||
Description: String("Allow users to view API Services (for admin)"),
|
||||
},
|
||||
{
|
||||
Name: "add_api_services",
|
||||
Description: String("Allow users to register new API Services (for admin)"),
|
||||
},
|
||||
{
|
||||
Name: "edit_api_services",
|
||||
Description: String("Allow users to edit API Services (for admin)"),
|
||||
},
|
||||
{
|
||||
Name: "remove_api_services",
|
||||
Description: String("Allow users to remove API Services (for admin)"),
|
||||
},
|
||||
{
|
||||
Name: "view_users",
|
||||
Description: String("Allow users to view other users (for admin)"),
|
||||
},
|
||||
{
|
||||
Name: "add_users",
|
||||
Description: String("Allow users to create new users (for admin)"),
|
||||
},
|
||||
// TODO: block, delete users
|
||||
{
|
||||
Name: "view_user_sessions",
|
||||
Description: String("Allow users to view user sessions (for admin)"),
|
||||
},
|
||||
{
|
||||
Name: "revoke_user_sessions",
|
||||
Description: String("Allow users to revoke user sessions (for admin)"),
|
||||
},
|
||||
{
|
||||
Name: "view_service_sessions",
|
||||
Description: String("Allow users to view service sessions (for admin)"),
|
||||
},
|
||||
{
|
||||
Name: "revoke_service_sessions",
|
||||
Description: String("Allow users to revoke service sessions (for admin)"),
|
||||
},
|
||||
{
|
||||
Name: "view_permissions",
|
||||
Description: String("Allow users to view all permissions (for admin)"),
|
||||
},
|
||||
{
|
||||
Name: "view_roles_groups",
|
||||
Description: String("Allow users to view roles & groups (for admin)"),
|
||||
},
|
||||
}
|
||||
SYSTEM_ROLES []RolePermissions = []RolePermissions{
|
||||
{
|
||||
Permissions: []string{
|
||||
"system_log_into_guard",
|
||||
"system_register",
|
||||
"system_edit_profile",
|
||||
"system_recover_credentials",
|
||||
"system_verify_profile",
|
||||
"system_access_home_services",
|
||||
"system_view_sessions",
|
||||
"system_revoke_sessions",
|
||||
"system_view_api_services",
|
||||
"system_add_api_services",
|
||||
"system_edit_api_services",
|
||||
"system_remove_api_services",
|
||||
"system_view_users",
|
||||
"system_add_users",
|
||||
"system_view_user_sessions",
|
||||
"system_revoke_user_sessions",
|
||||
"system_view_service_sessions",
|
||||
"system_revoke_service_sessions",
|
||||
"system_view_permissions",
|
||||
"system_view_roles_groups",
|
||||
},
|
||||
Role: repository.Role{
|
||||
Name: "admin",
|
||||
Description: String("User with full power"),
|
||||
},
|
||||
},
|
||||
{
|
||||
Permissions: []string{
|
||||
"system_log_into_guard",
|
||||
"system_register",
|
||||
"system_edit_profile",
|
||||
"system_recover_credentials",
|
||||
"system_verify_profile",
|
||||
"system_access_home_services",
|
||||
"system_view_sessions",
|
||||
"system_revoke_sessions",
|
||||
},
|
||||
Role: repository.Role{
|
||||
Name: "member",
|
||||
Description: String("User that is able to use home services"),
|
||||
},
|
||||
},
|
||||
{
|
||||
Permissions: []string{
|
||||
"system_log_into_guard",
|
||||
"system_register",
|
||||
},
|
||||
Role: repository.Role{
|
||||
Name: "guest",
|
||||
Description: String("New user that needs approve for everything from admin"),
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
func EnsureSystemPermissions(ctx context.Context, repo *repository.Queries) {
|
||||
for _, permission := range SYSTEM_PERMISSIONS {
|
||||
_, err := repo.FindPermission(ctx, repository.FindPermissionParams{
|
||||
Name: permission.Name,
|
||||
Scope: SYSTEM_SCOPE,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("INFO: Creating SYSTEM permission: '%s'\n", permission.Name)
|
||||
_, err = repo.CreatePermission(ctx, repository.CreatePermissionParams{
|
||||
Name: permission.Name,
|
||||
Scope: SYSTEM_SCOPE,
|
||||
Description: permission.Description,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("ERR: Failed to create SYSTEM permission: '%s'\n", permission.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, role := range SYSTEM_ROLES {
|
||||
var found repository.Role
|
||||
var err error
|
||||
|
||||
found, err = repo.FindRole(ctx, repository.FindRoleParams{
|
||||
Scope: SYSTEM_SCOPE,
|
||||
Name: role.Name,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("INFO: Create new SYSTEM role '%s'\n", role.Name)
|
||||
found, err = repo.CreateRole(ctx, repository.CreateRoleParams{
|
||||
Name: role.Name,
|
||||
Scope: SYSTEM_SCOPE,
|
||||
Description: role.Description,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("ERR: Failed to create SYSTEM role '%s': %v\n", role.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, perm := range role.Permissions {
|
||||
if _, exists := repo.GetRoleAssignment(ctx, repository.GetRoleAssignmentParams{
|
||||
RoleID: found.ID,
|
||||
Key: perm,
|
||||
}); exists != nil {
|
||||
if err := repo.AssignRolePermission(ctx, repository.AssignRolePermissionParams{
|
||||
RoleID: found.ID,
|
||||
Key: perm,
|
||||
}); err != nil {
|
||||
log.Fatalf("ERR: Failed to assign permission '%s' to SYSTEM role %s: %v\n", perm, found.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -38,7 +38,7 @@ func NewUserHandler(repo *repository.Queries, minio *storage.FileStorage, cfg *c
|
||||
|
||||
func (h *UserHandler) RegisterRoutes(api chi.Router) {
|
||||
api.Group(func(protected chi.Router) {
|
||||
authMiddleware := imiddleware.NewAuthMiddleware(h.cfg)
|
||||
authMiddleware := imiddleware.NewAuthMiddleware(h.cfg, h.repo)
|
||||
protected.Use(authMiddleware.Runner)
|
||||
|
||||
protected.Put("/avatar", h.uploadAvatar)
|
||||
@ -166,6 +166,7 @@ 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
|
||||
}
|
||||
@ -180,8 +181,16 @@ func (h *UserHandler) uploadAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
AvatarID string `json:"url"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@ -190,7 +199,7 @@ func (h *UserHandler) uploadAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(Response{AvatarID: uploadInfo.Key}); err != nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
@ -6,7 +6,6 @@ import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/types"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
@ -57,13 +56,12 @@ func SignJwtToken(claims jwt.Claims, key string) (string, error) {
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func VerifyToken(token string, key string) (*jwt.Token, *types.UserClaims, error) {
|
||||
func VerifyToken(token string, key string, claims jwt.Claims) (*jwt.Token, error) {
|
||||
publicKey, err := ParseBase64PublicKey(key)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
claims := &types.UserClaims{}
|
||||
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"])
|
||||
@ -72,12 +70,12 @@ func VerifyToken(token string, key string) (*jwt.Token, *types.UserClaims, error
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("invalid token: %w", err)
|
||||
return nil, fmt.Errorf("invalid token: %w", err)
|
||||
}
|
||||
|
||||
if !parsed.Valid {
|
||||
return nil, nil, fmt.Errorf("token is not valid")
|
||||
return nil, fmt.Errorf("token is not valid")
|
||||
}
|
||||
|
||||
return parsed, claims, nil
|
||||
return parsed, nil
|
||||
}
|
||||
|
46
internal/util/location.go
Normal file
46
internal/util/location.go
Normal file
@ -0,0 +1,46 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type LocationResult struct {
|
||||
Country string `json:"country"`
|
||||
Region string `json:"regionName"`
|
||||
City string `json:"city"`
|
||||
}
|
||||
|
||||
func GetLocation(ip string) (LocationResult, error) {
|
||||
var loc LocationResult
|
||||
// Example using ipinfo.io free API
|
||||
resp, err := http.Get("http://ip-api.com/json/" + ip + "?fields=25")
|
||||
if err != nil {
|
||||
return loc, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
json.NewDecoder(resp.Body).Decode(&loc)
|
||||
return loc, nil
|
||||
}
|
||||
|
||||
func GetClientIP(r *http.Request) string {
|
||||
// This header will be set by ngrok to the original client IP
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
log.Printf("DEBUG: Getting IP from X-Forwarded-For: %s\n", xff)
|
||||
// X-Forwarded-For: client, proxy1, proxy2, ...
|
||||
ips := strings.Split(xff, ",")
|
||||
if len(ips) > 0 {
|
||||
return strings.TrimSpace(ips[0])
|
||||
}
|
||||
}
|
||||
// Fallback to RemoteAddr (not the real client IP, but just in case)
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
log.Printf("DEBUG: Falling to request remote addr: %s (%s)\n", host, r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
@ -11,3 +11,7 @@ func GetRequestUserId(ctx context.Context) (string, bool) {
|
||||
return userId, ok
|
||||
}
|
||||
|
||||
func GetRequestJTI(ctx context.Context) (string, bool) {
|
||||
jti, ok := ctx.Value(types.JTIKey).(string)
|
||||
return jti, ok
|
||||
}
|
||||
|
39
internal/util/session.go
Normal file
39
internal/util/session.go
Normal file
@ -0,0 +1,39 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/types"
|
||||
"github.com/avct/uasurfer"
|
||||
)
|
||||
|
||||
func BuildDeviceInfo(userAgent string, remoteAddr string) []byte {
|
||||
var deviceInfo types.DeviceInfo
|
||||
|
||||
parsed := uasurfer.Parse(userAgent)
|
||||
|
||||
deviceInfo.Browser = parsed.Browser.Name.StringTrimPrefix()
|
||||
deviceInfo.BrowserVersion = fmt.Sprintf("%d.%d.%d", parsed.Browser.Version.Major, parsed.Browser.Version.Minor, parsed.Browser.Version.Patch)
|
||||
deviceInfo.DeviceName = fmt.Sprintf("%s %s", parsed.OS.Platform.StringTrimPrefix(), parsed.OS.Name.StringTrimPrefix())
|
||||
deviceInfo.DeviceType = parsed.DeviceType.StringTrimPrefix()
|
||||
deviceInfo.OS = parsed.OS.Platform.StringTrimPrefix()
|
||||
deviceInfo.OSVersion = fmt.Sprintf("%d.%d.%d", parsed.OS.Version.Major, parsed.OS.Version.Minor, parsed.OS.Version.Patch)
|
||||
deviceInfo.UserAgent = userAgent
|
||||
|
||||
if location, err := GetLocation(remoteAddr); err != nil {
|
||||
log.Printf("WARN: Failed to get location from ip (%s): %v\n", remoteAddr, err)
|
||||
} else {
|
||||
log.Printf("DEBUG: Response from IP fetcher: %#v\n", location)
|
||||
deviceInfo.Location = fmt.Sprintf("%s, %s, %s", location.Country, location.Region, location.City)
|
||||
}
|
||||
|
||||
serialized, err := json.Marshal(deviceInfo)
|
||||
if err != nil {
|
||||
log.Printf("ERR: Failed to serialize device info: %v\n", err)
|
||||
serialized = []byte{'{', '}'}
|
||||
}
|
||||
|
||||
return serialized
|
||||
}
|
12
migrations/00008_add_verification_levels.sql
Normal file
12
migrations/00008_add_verification_levels.sql
Normal 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
|
12
migrations/00009_add_complete_verify.sql
Normal file
12
migrations/00009_add_complete_verify.sql
Normal 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
|
12
migrations/00010_add_api_service_icon_url.sql
Normal file
12
migrations/00010_add_api_service_icon_url.sql
Normal 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
|
34
migrations/00011_add_user_sessions_table.sql
Normal file
34
migrations/00011_add_user_sessions_table.sql
Normal file
@ -0,0 +1,34 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
CREATE TABLE user_sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid (),
|
||||
user_id UUID REFERENCES users (id) NOT NULL,
|
||||
session_type VARCHAR(32) NOT NULL DEFAULT 'user', -- e.g. 'user', 'admin'
|
||||
issued_at TIMESTAMP
|
||||
WITH
|
||||
TIME ZONE NOT NULL DEFAULT NOW (),
|
||||
expires_at TIMESTAMP
|
||||
WITH
|
||||
TIME ZONE,
|
||||
last_active TIMESTAMP
|
||||
WITH
|
||||
TIME ZONE,
|
||||
ip_address VARCHAR(45), -- supports IPv4/IPv6
|
||||
user_agent TEXT,
|
||||
access_token_id UUID,
|
||||
refresh_token_id UUID,
|
||||
device_info JSONB, -- optional: structured info (browser, OS, etc.)
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
revoked_at TIMESTAMP
|
||||
WITH
|
||||
TIME ZONE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions (user_id);
|
||||
|
||||
-- +goose StatementEnd
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE IF EXISTS user_sessions;
|
||||
|
||||
-- +goose StatementEnd
|
38
migrations/00012_add_service_sessions.sql
Normal file
38
migrations/00012_add_service_sessions.sql
Normal file
@ -0,0 +1,38 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
CREATE TABLE service_sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid (),
|
||||
service_id UUID REFERENCES api_services (id) NOT NULL,
|
||||
client_id TEXT NOT NULL,
|
||||
user_id UUID REFERENCES users (id), -- user on behalf of whom the service is acting, nullable for direct use with client creds
|
||||
issued_at TIMESTAMP
|
||||
WITH
|
||||
TIME ZONE NOT NULL DEFAULT NOW (),
|
||||
expires_at TIMESTAMP
|
||||
WITH
|
||||
TIME ZONE,
|
||||
last_active TIMESTAMP
|
||||
WITH
|
||||
TIME ZONE,
|
||||
ip_address VARCHAR(45),
|
||||
user_agent TEXT,
|
||||
access_token_id UUID,
|
||||
refresh_token_id UUID,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
revoked_at TIMESTAMP
|
||||
WITH
|
||||
TIME ZONE,
|
||||
scope TEXT, -- what scopes/permissions this session was issued for
|
||||
claims JSONB -- snapshot of claims at session start, optional
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_service_sessions_client_id ON service_sessions (client_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_service_sessions_user_id ON service_sessions (user_id);
|
||||
|
||||
-- +goose StatementEnd
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE IF EXISTS service_sessions;
|
||||
|
||||
-- +goose StatementEnd
|
55
migrations/00013_add_group_role_permission.sql
Normal file
55
migrations/00013_add_group_role_permission.sql
Normal file
@ -0,0 +1,55 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
-- ROLES
|
||||
CREATE TABLE roles (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid (),
|
||||
name TEXT NOT NULL,
|
||||
scope TEXT NOT NULL,
|
||||
description TEXT,
|
||||
UNIQUE (name, scope)
|
||||
);
|
||||
|
||||
-- PERMISSIONS
|
||||
CREATE TABLE permissions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid (),
|
||||
name TEXT NOT NULL,
|
||||
scope TEXT NOT NULL,
|
||||
description TEXT,
|
||||
UNIQUE (name, scope)
|
||||
);
|
||||
|
||||
-- ROLE-PERMISSIONS (many-to-many)
|
||||
CREATE TABLE role_permissions (
|
||||
role_id UUID REFERENCES roles (id) ON DELETE CASCADE,
|
||||
permission_id UUID REFERENCES permissions (id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (role_id, permission_id)
|
||||
);
|
||||
|
||||
-- USER-ROLES (direct assignment, optional)
|
||||
CREATE TABLE user_roles (
|
||||
user_id UUID REFERENCES users (id) ON DELETE CASCADE,
|
||||
role_id UUID REFERENCES roles (id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (user_id, role_id)
|
||||
);
|
||||
|
||||
-- USER-PERMISSIONS (direct assignment, optional)
|
||||
CREATE TABLE user_permissions (
|
||||
user_id UUID REFERENCES users (id) ON DELETE CASCADE,
|
||||
permission_id UUID REFERENCES permissions (id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (user_id, permission_id)
|
||||
);
|
||||
|
||||
-- +goose StatementEnd
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE IF EXISTS user_permissions;
|
||||
|
||||
DROP TABLE IF EXISTS user_roles;
|
||||
|
||||
DROP TABLE IF EXISTS role_permissions;
|
||||
|
||||
DROP TABLE IF EXISTS permissions;
|
||||
|
||||
DROP TABLE IF EXISTS roles;
|
||||
|
||||
-- +goose StatementEnd
|
29
queries/permissions.sql
Normal file
29
queries/permissions.sql
Normal file
@ -0,0 +1,29 @@
|
||||
|
||||
-- name: GetAllPermissions :many
|
||||
SELECT *
|
||||
FROM permissions p
|
||||
ORDER BY p.scope;
|
||||
|
||||
-- name: GetGroupedPermissions :many
|
||||
SELECT scope, json_agg(to_jsonb(permissions.*) ORDER BY name) as permissions
|
||||
FROM permissions
|
||||
GROUP BY scope;
|
||||
|
||||
-- name: CreatePermission :one
|
||||
INSERT into permissions (
|
||||
name, scope, description
|
||||
) VALUES (
|
||||
$1, $2, $3
|
||||
) RETURNING *;
|
||||
|
||||
-- name: FindPermission :one
|
||||
SELECT * FROM permissions
|
||||
WHERE name = $1 AND scope = $2;
|
||||
|
||||
-- name: GetUserPermissions :many
|
||||
SELECT DISTINCT p.id,p.name,p.scope,p.description
|
||||
FROM user_roles ur
|
||||
JOIN role_permissions rp ON ur.role_id = rp.role_id
|
||||
JOIN permissions p ON rp.permission_id = p.id
|
||||
WHERE ur.user_id = $1
|
||||
ORDER BY p.scope;
|
109
queries/roles.sql
Normal file
109
queries/roles.sql
Normal file
@ -0,0 +1,109 @@
|
||||
-- name: FindRole :one
|
||||
SELECT *
|
||||
FROM roles
|
||||
WHERE scope = $1 AND name = $2;
|
||||
|
||||
-- name: GetRolesGroupedWithPermissions :many
|
||||
SELECT
|
||||
r.scope,
|
||||
json_agg (
|
||||
json_build_object (
|
||||
'id',
|
||||
r.id,
|
||||
'name',
|
||||
r.name,
|
||||
'scope',
|
||||
r.scope,
|
||||
'description',
|
||||
r.description,
|
||||
'permissions',
|
||||
COALESCE(p_list.permissions, '[]')
|
||||
)
|
||||
ORDER BY
|
||||
r.name
|
||||
) AS roles
|
||||
FROM
|
||||
roles r
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
rp.role_id,
|
||||
json_agg (
|
||||
json_build_object (
|
||||
'id',
|
||||
p.id,
|
||||
'name',
|
||||
p.name,
|
||||
'scope',
|
||||
p.scope,
|
||||
'description',
|
||||
p.description
|
||||
)
|
||||
ORDER BY
|
||||
p.name
|
||||
) AS permissions
|
||||
FROM
|
||||
role_permissions rp
|
||||
JOIN permissions p ON p.id = rp.permission_id
|
||||
GROUP BY
|
||||
rp.role_id
|
||||
) p_list ON p_list.role_id = r.id
|
||||
GROUP BY
|
||||
r.scope;
|
||||
|
||||
-- name: CreateRole :one
|
||||
INSERT INTO roles (name, scope, description)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetRoleAssignment :one
|
||||
SELECT * FROM role_permissions
|
||||
WHERE role_id = $1 AND permission_id = (SELECT id FROM permissions p WHERE p.scope = split_part(sqlc.arg('key'), '_', 1) AND p.name = right(sqlc.arg('key'), length(sqlc.arg('key')) - position('_' IN sqlc.arg('key'))))
|
||||
LIMIT 1;
|
||||
|
||||
-- name: AssignRolePermission :exec
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
VALUES (
|
||||
$1,
|
||||
(
|
||||
SELECT id
|
||||
FROM permissions p
|
||||
WHERE p.scope = split_part(sqlc.arg('key'), '_', 1)
|
||||
AND p.name = right(sqlc.arg('key'), length(sqlc.arg('key')) - position('_' IN sqlc.arg('key')))
|
||||
)
|
||||
);
|
||||
-- name: AddPermissionsToRoleByKey :exec
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
SELECT
|
||||
sqlc.arg(role_id),
|
||||
p.id
|
||||
FROM
|
||||
permissions p
|
||||
JOIN
|
||||
unnest(sqlc.arg(permission_keys)::text[]) AS key_str
|
||||
ON key_str = p.scope || '_' || p.name;
|
||||
|
||||
-- name: GetUserRoles :many
|
||||
SELECT r.* FROM roles r
|
||||
JOIN user_roles ur ON r.id = ur.role_id
|
||||
WHERE ur.user_id = $1;
|
||||
|
||||
-- name: AssignUserRole :exec
|
||||
INSERT INTO user_roles (user_id, role_id)
|
||||
VALUES ($1, (
|
||||
SELECT id FROM roles r
|
||||
WHERE r.scope = split_part(sqlc.arg('key'), '_', 1)
|
||||
AND r.name = right(sqlc.arg('key'), length(sqlc.arg('key')) - position('_' IN sqlc.arg('key')))
|
||||
));
|
||||
|
||||
-- name: UnassignUserRole :exec
|
||||
DELETE FROM user_roles
|
||||
WHERE user_id = $1 AND role_id = (
|
||||
SELECT id FROM roles r
|
||||
WHERE r.scope = split_part(sqlc.arg('key'), '_', 1)
|
||||
AND r.name = right(sqlc.arg('key'), length(sqlc.arg('key')) - position('_' IN sqlc.arg('key')))
|
||||
);
|
||||
|
||||
-- name: FindUserRole :one
|
||||
SELECT * FROM user_roles
|
||||
WHERE user_id = $1 AND role_id = (SELECT id FROM roles r WHERE r.scope = split_part(sqlc.arg('key'), '_', 1) AND r.name = right(sqlc.arg('key'), length(sqlc.arg('key')) - position('_' IN sqlc.arg('key'))))
|
||||
LIMIT 1;
|
69
queries/service_sessions.sql
Normal file
69
queries/service_sessions.sql
Normal file
@ -0,0 +1,69 @@
|
||||
-- name: CreateServiceSession :one
|
||||
INSERT INTO service_sessions (
|
||||
service_id, client_id, user_id, issued_at, expires_at, last_active,
|
||||
ip_address, user_agent, access_token_id, refresh_token_id,
|
||||
is_active, scope, claims
|
||||
) VALUES (
|
||||
$1, $2, $3, NOW(), $4, $5,
|
||||
$6, $7, $8, $9,
|
||||
TRUE, $10, $11
|
||||
)
|
||||
RETURNING *;
|
||||
|
||||
-- name: ListActiveServiceSessionsByClient :many
|
||||
SELECT * FROM service_sessions
|
||||
WHERE client_id = $1
|
||||
AND is_active = TRUE
|
||||
ORDER BY issued_at DESC
|
||||
LIMIT $1 OFFSET $2;
|
||||
|
||||
-- name: ListActiveServiceSessionsByUser :many
|
||||
SELECT * FROM service_sessions
|
||||
WHERE user_id = $1
|
||||
AND is_active = TRUE
|
||||
ORDER BY issued_at DESC
|
||||
LIMIT $1 OFFSET $2;
|
||||
|
||||
-- name: GetServiceSessionByAccessJTI :one
|
||||
SELECT * FROM service_sessions
|
||||
WHERE access_token_id = $1
|
||||
AND is_active = TRUE;
|
||||
|
||||
-- name: GetServiceSessionByRefreshJTI :one
|
||||
SELECT * FROM service_sessions
|
||||
WHERE refresh_token_id = $1;
|
||||
|
||||
-- name: RevokeServiceSession :exec
|
||||
UPDATE service_sessions
|
||||
SET is_active = FALSE,
|
||||
revoked_at = NOW()
|
||||
WHERE id = $1
|
||||
AND is_active = TRUE;
|
||||
|
||||
-- name: UpdateServiceSessionLastActive :exec
|
||||
UPDATE service_sessions
|
||||
SET last_active = NOW()
|
||||
WHERE id = $1
|
||||
AND is_active = TRUE;
|
||||
|
||||
-- name: UpdateServiceSessionTokens :exec
|
||||
UPDATE service_sessions
|
||||
SET access_token_id = $2, refresh_token_id = $3, expires_at = $4
|
||||
WHERE id = $1
|
||||
AND is_active = TRUE;
|
||||
|
||||
-- name: ListAllServiceSessions :many
|
||||
SELECT * FROM service_sessions
|
||||
ORDER BY issued_at DESC
|
||||
LIMIT $1 OFFSET $2;
|
||||
|
||||
-- name: GetServiceSessions :many
|
||||
SELECT sqlc.embed(session), sqlc.embed(service), sqlc.embed(u)
|
||||
FROM service_sessions AS session
|
||||
JOIN api_services AS service ON service.id = session.service_id
|
||||
JOIN users AS u ON u.id = session.user_id
|
||||
ORDER BY session.issued_at DESC
|
||||
LIMIT $1 OFFSET $2;
|
||||
|
||||
-- name: GetServiceSessionsCount :one
|
||||
SELECT COUNT(*) FROM service_sessions;
|
60
queries/user_sessions.sql
Normal file
60
queries/user_sessions.sql
Normal file
@ -0,0 +1,60 @@
|
||||
-- name: CreateUserSession :one
|
||||
INSERT INTO user_sessions (
|
||||
user_id, session_type, issued_at, expires_at, last_active,
|
||||
ip_address, user_agent, access_token_id, refresh_token_id,
|
||||
device_info, is_active
|
||||
) VALUES (
|
||||
$1, $2, NOW(), $3, $4,
|
||||
$5, $6, $7, $8,
|
||||
$9, TRUE
|
||||
)
|
||||
RETURNING *;
|
||||
|
||||
-- name: ListActiveUserSessions :many
|
||||
SELECT * FROM user_sessions
|
||||
WHERE user_id = $1
|
||||
AND is_active = TRUE
|
||||
ORDER BY issued_at DESC;
|
||||
|
||||
-- name: GetUserSessionByAccessJTI :one
|
||||
SELECT * FROM user_sessions
|
||||
WHERE access_token_id = $1
|
||||
AND is_active = TRUE;
|
||||
|
||||
-- name: GetUserSessionByRefreshJTI :one
|
||||
SELECT * FROM user_sessions
|
||||
WHERE refresh_token_id = $1;
|
||||
|
||||
-- name: RevokeUserSession :exec
|
||||
UPDATE user_sessions
|
||||
SET is_active = FALSE,
|
||||
revoked_at = NOW()
|
||||
WHERE id = $1
|
||||
AND is_active = TRUE;
|
||||
|
||||
-- name: UpdateSessionLastActive :exec
|
||||
UPDATE user_sessions
|
||||
SET last_active = NOW()
|
||||
WHERE id = $1
|
||||
AND is_active = TRUE;
|
||||
|
||||
-- name: UpdateSessionTokens :exec
|
||||
UPDATE user_sessions
|
||||
SET access_token_id = $2, refresh_token_id = $3, expires_at = $4
|
||||
WHERE id = $1
|
||||
AND is_active = TRUE;
|
||||
|
||||
-- name: ListAllSessions :many
|
||||
SELECT * FROM user_sessions
|
||||
ORDER BY issued_at DESC
|
||||
LIMIT $1 OFFSET $2;
|
||||
|
||||
-- name: GetUserSessions :many
|
||||
SELECT sqlc.embed(session), sqlc.embed(u)
|
||||
FROM user_sessions AS session
|
||||
JOIN users AS u ON u.id = session.user_id
|
||||
ORDER BY session.issued_at DESC
|
||||
LIMIT $1 OFFSET $2;
|
||||
|
||||
-- name: GetUserSessionsCount :one
|
||||
SELECT COUNT(*) FROM user_sessions;
|
@ -23,6 +23,21 @@ 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()
|
||||
|
4
redis.conf
Normal file
4
redis.conf
Normal file
@ -0,0 +1,4 @@
|
||||
|
||||
# Enable ACL
|
||||
user default off
|
||||
user guard on >guard allcommands allkeys
|
25
sqlc.yaml
25
sqlc.yaml
@ -41,20 +41,39 @@ sql:
|
||||
# ───── text ──────────────────────────────────────────
|
||||
- db_type: "pg_catalog.text"
|
||||
go_type: { type: "string" }
|
||||
- db_type: "text" # or just "bool"
|
||||
|
||||
- db_type: "text"
|
||||
go_type: { type: "string" }
|
||||
|
||||
- db_type: "pg_catalog.text"
|
||||
nullable: true
|
||||
go_type:
|
||||
type: "string"
|
||||
pointer: true # ⇒ *bool for NULLable columns
|
||||
pointer: true
|
||||
|
||||
- db_type: "text"
|
||||
nullable: true
|
||||
go_type:
|
||||
type: "string"
|
||||
pointer: true # ⇒ *bool for NULLable columns
|
||||
pointer: true
|
||||
|
||||
- db_type: "pg_catalog.varchar"
|
||||
go_type: { type: "string" }
|
||||
|
||||
- db_type: "varchar"
|
||||
go_type: { type: "string" }
|
||||
|
||||
- db_type: "pg_catalog.varchar"
|
||||
nullable: true
|
||||
go_type:
|
||||
type: "string"
|
||||
pointer: true
|
||||
|
||||
- db_type: "varchar"
|
||||
nullable: true
|
||||
go_type:
|
||||
type: "string"
|
||||
pointer: true
|
||||
|
||||
# ───── timestamp (WITHOUT TZ) ────────────────────────
|
||||
- db_type: "pg_catalog.timestamp" # or "timestamp"
|
||||
|
349
web/package-lock.json
generated
349
web/package-lock.json
generated
@ -8,11 +8,11 @@
|
||||
"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",
|
||||
"moment": "^2.30.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
@ -58,6 +58,7 @@
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
||||
"integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.27.1",
|
||||
@ -113,6 +114,7 @@
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.1.tgz",
|
||||
"integrity": "sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.27.1",
|
||||
@ -146,6 +148,7 @@
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz",
|
||||
"integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/traverse": "^7.27.1",
|
||||
@ -187,6 +190,7 @@
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@ -196,6 +200,7 @@
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
|
||||
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@ -229,6 +234,7 @@
|
||||
"version": "7.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.2.tgz",
|
||||
"integrity": "sha512-QYLs8299NA7WM/bZAdp+CviYYkVoYXlDW2rzliy3chxd1PQjej7JORuMJDJXJUb9g0TT+B99EwaVLKmX+sPXWw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.27.1"
|
||||
@ -272,19 +278,11 @@
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.1.tgz",
|
||||
"integrity": "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
|
||||
"integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
@ -299,6 +297,7 @@
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.1.tgz",
|
||||
"integrity": "sha512-ZCYtZciz1IWJB4U61UPu4KEaqyfj+r5T1Q5mqPo+IBpcG9kHv30Z0aD8LXPgC1trYa6rK0orRyAhqUgk4MjmEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
@ -317,6 +316,7 @@
|
||||
"version": "11.12.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
|
||||
"integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
@ -326,6 +326,7 @@
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.1.tgz",
|
||||
"integrity": "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
@ -335,126 +336,6 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emotion/babel-plugin": {
|
||||
"version": "11.13.5",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
|
||||
"integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-module-imports": "^7.16.7",
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"@emotion/hash": "^0.9.2",
|
||||
"@emotion/memoize": "^0.9.0",
|
||||
"@emotion/serialize": "^1.3.3",
|
||||
"babel-plugin-macros": "^3.1.0",
|
||||
"convert-source-map": "^1.5.0",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"find-root": "^1.1.0",
|
||||
"source-map": "^0.5.7",
|
||||
"stylis": "4.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emotion/babel-plugin/node_modules/convert-source-map": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
|
||||
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@emotion/cache": {
|
||||
"version": "11.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz",
|
||||
"integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@emotion/memoize": "^0.9.0",
|
||||
"@emotion/sheet": "^1.4.0",
|
||||
"@emotion/utils": "^1.4.2",
|
||||
"@emotion/weak-memoize": "^0.4.0",
|
||||
"stylis": "4.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emotion/hash": {
|
||||
"version": "0.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
|
||||
"integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@emotion/memoize": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz",
|
||||
"integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@emotion/react": {
|
||||
"version": "11.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
|
||||
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
"@emotion/cache": "^11.14.0",
|
||||
"@emotion/serialize": "^1.3.3",
|
||||
"@emotion/use-insertion-effect-with-fallbacks": "^1.2.0",
|
||||
"@emotion/utils": "^1.4.2",
|
||||
"@emotion/weak-memoize": "^0.4.0",
|
||||
"hoist-non-react-statics": "^3.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@emotion/serialize": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz",
|
||||
"integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@emotion/hash": "^0.9.2",
|
||||
"@emotion/memoize": "^0.9.0",
|
||||
"@emotion/unitless": "^0.10.0",
|
||||
"@emotion/utils": "^1.4.2",
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@emotion/sheet": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz",
|
||||
"integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@emotion/unitless": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz",
|
||||
"integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@emotion/use-insertion-effect-with-fallbacks": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz",
|
||||
"integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emotion/utils": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz",
|
||||
"integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@emotion/weak-memoize": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz",
|
||||
"integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz",
|
||||
@ -2068,12 +1949,6 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/parse-json": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
|
||||
"integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.4.tgz",
|
||||
@ -2423,21 +2298,6 @@
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-plugin-macros": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz",
|
||||
"integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"cosmiconfig": "^7.0.0",
|
||||
"resolve": "^1.19.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10",
|
||||
"npm": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
@ -2519,6 +2379,7 @@
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||
"integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
@ -2642,31 +2503,6 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/cosmiconfig": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
|
||||
"integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/parse-json": "^4.0.0",
|
||||
"import-fresh": "^3.2.1",
|
||||
"parse-json": "^5.0.0",
|
||||
"path-type": "^4.0.0",
|
||||
"yaml": "^1.10.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/cosmiconfig/node_modules/yaml": {
|
||||
"version": "1.10.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
|
||||
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@ -2686,12 +2522,14 @@
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
@ -2768,15 +2606,6 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/error-ex": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
|
||||
"integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-arrayish": "^0.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
@ -2876,6 +2705,7 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
@ -3149,12 +2979,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/find-root": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
|
||||
"integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
|
||||
@ -3398,15 +3222,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/hoist-non-react-statics": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
|
||||
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"react-is": "^16.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/idb": {
|
||||
"version": "8.0.3",
|
||||
"resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz",
|
||||
@ -3434,6 +3249,7 @@
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
"integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"parent-module": "^1.0.0",
|
||||
@ -3463,27 +3279,6 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/is-arrayish": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
|
||||
"integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-core-module": {
|
||||
"version": "2.16.1",
|
||||
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
|
||||
"integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
@ -3537,6 +3332,7 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
@ -3556,6 +3352,7 @@
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jsesc": "bin/jsesc"
|
||||
@ -3571,12 +3368,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-parse-even-better-errors": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
|
||||
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
@ -3865,12 +3656,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",
|
||||
@ -4025,10 +3810,20 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/moment": {
|
||||
"version": "2.30.1",
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
|
||||
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
@ -4134,6 +3929,7 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
"integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"callsites": "^3.0.0"
|
||||
@ -4142,24 +3938,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-json": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
|
||||
"integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.0.0",
|
||||
"error-ex": "^1.3.1",
|
||||
"json-parse-even-better-errors": "^2.3.0",
|
||||
"lines-and-columns": "^1.1.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/path": {
|
||||
"version": "0.12.7",
|
||||
"resolved": "https://registry.npmjs.org/path/-/path-0.12.7.tgz",
|
||||
@ -4191,21 +3969,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-parse": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-type": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
|
||||
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@ -4372,12 +4135,6 @@
|
||||
"react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-jwt": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/react-jwt/-/react-jwt-1.3.0.tgz",
|
||||
@ -4439,30 +4196,11 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.10",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
|
||||
"integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-core-module": "^2.16.0",
|
||||
"path-parse": "^1.0.7",
|
||||
"supports-preserve-symlinks-flag": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"resolve": "bin/resolve"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-from": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
@ -4608,15 +4346,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.5.7",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
|
||||
"integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@ -4639,12 +4368,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/stylis": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz",
|
||||
"integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
@ -4658,18 +4381,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",
|
||||
|
@ -11,11 +11,11 @@
|
||||
"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",
|
||||
"moment": "^2.30.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 416 KiB After Width: | Height: | Size: 59 KiB |
@ -1,5 +1,5 @@
|
||||
import { type FC } from "react";
|
||||
import { createBrowserRouter, RouterProvider } from "react-router";
|
||||
import { createBrowserRouter, Navigate, RouterProvider } from "react-router";
|
||||
|
||||
import IndexPage from "./pages/Index";
|
||||
import LoginPage from "./pages/Login";
|
||||
@ -19,6 +19,16 @@ 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";
|
||||
import AdminUserSessionsPage from "./pages/Admin/UserSessions";
|
||||
import AdminServiceSessionsPage from "./pages/Admin/ServiceSessions";
|
||||
import AdminAppPermissionsPage from "./pages/Admin/AppPermissions";
|
||||
import AdminRolesGroupsPage from "./pages/Admin/RolesGroups";
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
@ -41,6 +51,10 @@ const router = createBrowserRouter([
|
||||
path: "admin",
|
||||
element: <AdminLayout />,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <Navigate to="/admin/api-services" />,
|
||||
},
|
||||
{
|
||||
path: "api-services",
|
||||
children: [
|
||||
@ -71,8 +85,60 @@ const router = createBrowserRouter([
|
||||
// },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "user-sessions",
|
||||
children: [{ index: true, element: <AdminUserSessionsPage /> }],
|
||||
},
|
||||
{
|
||||
path: "service-sessions",
|
||||
children: [
|
||||
{ index: true, element: <AdminServiceSessionsPage /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "app-permissions",
|
||||
children: [
|
||||
{ index: true, element: <AdminAppPermissionsPage /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "roles-groups",
|
||||
children: [{ index: true, element: <AdminRolesGroupsPage /> }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
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 />,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
@ -56,6 +56,19 @@ export const getApiService = async (id: string): Promise<ApiService> => {
|
||||
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}`);
|
||||
|
||||
|
34
web/src/api/admin/permissions.ts
Normal file
34
web/src/api/admin/permissions.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import type { AppPermission } from "@/types";
|
||||
import { axios, handleApiError } from "..";
|
||||
|
||||
export type FetchPermissionsResponse = AppPermission[];
|
||||
|
||||
export const getUserPermissionsApi = async (
|
||||
userId: string,
|
||||
): Promise<FetchPermissionsResponse> => {
|
||||
const response = await axios.get<FetchPermissionsResponse>(
|
||||
`/api/v1/admin/permissions/${userId}`,
|
||||
);
|
||||
|
||||
if (response.status !== 200 && response.status !== 201)
|
||||
throw await handleApiError(response);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export type FetchGroupedPermissionsResponse = {
|
||||
scope: string;
|
||||
permissions: AppPermission[];
|
||||
}[];
|
||||
|
||||
export const getPermissionsApi =
|
||||
async (): Promise<FetchGroupedPermissionsResponse> => {
|
||||
const response = await axios.get<FetchGroupedPermissionsResponse>(
|
||||
"/api/v1/admin/permissions",
|
||||
);
|
||||
|
||||
if (response.status !== 200 && response.status !== 201)
|
||||
throw await handleApiError(response);
|
||||
|
||||
return response.data;
|
||||
};
|
51
web/src/api/admin/roles.ts
Normal file
51
web/src/api/admin/roles.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import type { AppPermission, AppRole } from "@/types";
|
||||
import { axios, handleApiError } from "..";
|
||||
|
||||
export type FetchGroupedRolesResponse = {
|
||||
scope: string;
|
||||
roles: (AppRole & {
|
||||
permissions: AppPermission[];
|
||||
})[];
|
||||
}[];
|
||||
|
||||
export const getRolesApi = async (): Promise<FetchGroupedRolesResponse> => {
|
||||
const response = await axios.get<FetchGroupedRolesResponse>(
|
||||
"/api/v1/admin/roles",
|
||||
);
|
||||
|
||||
if (response.status !== 200 && response.status !== 201)
|
||||
throw await handleApiError(response);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export type FetchUserRolesResponse = AppRole[];
|
||||
|
||||
export const getUserRolesApi = async (
|
||||
userId: string,
|
||||
): Promise<FetchUserRolesResponse> => {
|
||||
const response = await axios.get<FetchUserRolesResponse>(
|
||||
`/api/v1/admin/roles/${userId}`,
|
||||
);
|
||||
|
||||
if (response.status !== 200 && response.status !== 201)
|
||||
throw await handleApiError(response);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export interface AssignUserRoleRequest {
|
||||
role_key: string;
|
||||
}
|
||||
|
||||
export const assignUserRoleApi = async (
|
||||
userId: string,
|
||||
params: AssignUserRoleRequest,
|
||||
) => {
|
||||
const response = await axios.patch(`/api/v1/admin/roles/${userId}`, params);
|
||||
|
||||
if (response.status !== 200 && response.status !== 201)
|
||||
throw await handleApiError(response);
|
||||
|
||||
return response.data;
|
||||
};
|
78
web/src/api/admin/sessions.ts
Normal file
78
web/src/api/admin/sessions.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import type { ServiceSession, UserSession } from "@/types";
|
||||
import { axios, handleApiError } from "..";
|
||||
|
||||
export interface FetchUserSessionsRequest {
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface FetchUserSessionsResponse {
|
||||
items: UserSession[];
|
||||
page: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export const adminGetUserSessionsApi = async (
|
||||
req: FetchUserSessionsRequest,
|
||||
): Promise<FetchUserSessionsResponse> => {
|
||||
const response = await axios.get<FetchUserSessionsResponse>(
|
||||
"/api/v1/admin/user-sessions",
|
||||
{
|
||||
params: req,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.status !== 200 && response.status !== 201)
|
||||
throw await handleApiError(response);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const adminRevokeUserSessionApi = async (
|
||||
sessionId: string,
|
||||
): Promise<void> => {
|
||||
const response = await axios.patch<FetchServiceSessionsResponse>(
|
||||
`/api/v1/admin/user-sessions/revoke/${sessionId}`,
|
||||
);
|
||||
|
||||
if (response.status !== 200 && response.status !== 201)
|
||||
throw await handleApiError(response);
|
||||
};
|
||||
|
||||
export interface FetchServiceSessionsRequest {
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface FetchServiceSessionsResponse {
|
||||
items: ServiceSession[];
|
||||
page: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export const adminGetServiceSessionsApi = async (
|
||||
req: FetchServiceSessionsRequest,
|
||||
): Promise<FetchServiceSessionsResponse> => {
|
||||
const response = await axios.get<FetchServiceSessionsResponse>(
|
||||
"/api/v1/admin/service-sessions",
|
||||
{
|
||||
params: req,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.status !== 200 && response.status !== 201)
|
||||
throw await handleApiError(response);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const adminRevokeServiceSessionApi = async (
|
||||
sessionId: string,
|
||||
): Promise<void> => {
|
||||
const response = await axios.patch<FetchServiceSessionsResponse>(
|
||||
`/api/v1/admin/service-sessions/revoke/${sessionId}`,
|
||||
);
|
||||
|
||||
if (response.status !== 200 && response.status !== 201)
|
||||
throw await handleApiError(response);
|
||||
};
|
17
web/src/api/avatar.ts
Normal file
17
web/src/api/avatar.ts
Normal 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;
|
||||
};
|
@ -4,10 +4,14 @@ export interface CodeResponse {
|
||||
code: string;
|
||||
}
|
||||
|
||||
export const codeApi = async (accessToken: string, nonce: string) => {
|
||||
export const codeApi = async (
|
||||
accessToken: string,
|
||||
nonce: string,
|
||||
clientId: string,
|
||||
) => {
|
||||
const response = await axios.post(
|
||||
"/api/v1/oauth/code",
|
||||
{ nonce },
|
||||
{ nonce, client_id: clientId },
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
@ -27,11 +27,13 @@ const processRefreshQueue = async (token: string | null) => {
|
||||
|
||||
const logout = async (accountId: string) => {
|
||||
const db = useDbStore.getState().db;
|
||||
const requireSignIn = useAuth.getState().requireSignIn;
|
||||
const { requireSignIn, loadAccounts } = useAuth.getState();
|
||||
|
||||
if (db) {
|
||||
await deleteAccount(db, accountId);
|
||||
}
|
||||
await loadAccounts();
|
||||
|
||||
requireSignIn?.();
|
||||
};
|
||||
|
||||
|
21
web/src/api/signout.ts
Normal file
21
web/src/api/signout.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import axios from "axios";
|
||||
import { handleApiError } from ".";
|
||||
|
||||
export const signoutApi = async (token: string) => {
|
||||
const response = await axios.post(
|
||||
"/api/v1/auth/signout",
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (response.status !== 200 && response.status !== 201)
|
||||
throw await handleApiError(response);
|
||||
|
||||
const data = response.data;
|
||||
|
||||
return data;
|
||||
};
|
34
web/src/api/verify.ts
Normal file
34
web/src/api/verify.ts
Normal 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;
|
||||
};
|
@ -7,7 +7,8 @@ const Sidebar: FC = () => {
|
||||
|
||||
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) => (
|
||||
{barItems.map((item, index) =>
|
||||
item.type !== "delimiter" ? (
|
||||
<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${
|
||||
@ -17,7 +18,21 @@ const Sidebar: FC = () => {
|
||||
{item.icon} {item.title}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
) : (
|
||||
<div
|
||||
key={item.key}
|
||||
className={`flex flex-row items-center gap-4 my-2 ${index === 0 ? "mt-0" : "mt-4"}`}
|
||||
>
|
||||
<div className="w-full h-[2px] rounded-lg bg-gray-800"></div>
|
||||
{typeof item.title === "string" && (
|
||||
<p className="text-gray-800 dark:text-gray-400 text-sm">
|
||||
{item.title}
|
||||
</p>
|
||||
)}
|
||||
<div className="w-full h-[2px] rounded-lg bg-gray-800"></div>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -6,8 +6,10 @@ 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 pt-14">
|
||||
{barItems.map((item) => (
|
||||
<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
|
||||
.filter((item) => item.type !== "delimiter")
|
||||
.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 ${
|
||||
|
64
web/src/components/ui/pagination.tsx
Normal file
64
web/src/components/ui/pagination.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react";
|
||||
import React, { useCallback } from "react";
|
||||
import { Button } from "./button";
|
||||
|
||||
type PaginationProps = {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
};
|
||||
|
||||
const Pagination: React.FC<PaginationProps> = ({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
}) => {
|
||||
const getPageNumbers = useCallback(() => {
|
||||
const delta = 2;
|
||||
const pages = [];
|
||||
|
||||
for (
|
||||
let i = Math.max(1, currentPage - delta);
|
||||
i <= Math.min(totalPages, currentPage + delta);
|
||||
i++
|
||||
) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
return pages;
|
||||
}, [currentPage, totalPages]);
|
||||
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
return (
|
||||
<nav className="flex justify-center items-center gap-2 mt-4">
|
||||
<Button
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
variant="outlined"
|
||||
>
|
||||
<ArrowLeft size={17} />
|
||||
</Button>
|
||||
|
||||
{getPageNumbers().map((page) => (
|
||||
<Button
|
||||
key={page}
|
||||
onClick={() => onPageChange(page)}
|
||||
variant={page === currentPage ? "contained" : "outlined"}
|
||||
>
|
||||
<p className="text-sm">{page}</p>
|
||||
</Button>
|
||||
))}
|
||||
|
||||
<Button
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
variant="outlined"
|
||||
>
|
||||
<ArrowRight size={17} />
|
||||
</Button>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default Pagination;
|
91
web/src/components/ui/stepper.tsx
Normal file
91
web/src/components/ui/stepper.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -5,7 +5,7 @@ import { useMemo, type FC } from "react";
|
||||
export interface AvatarProps {
|
||||
iconSize?: number;
|
||||
className?: string;
|
||||
avatarId?: string;
|
||||
avatarId?: string | null;
|
||||
}
|
||||
|
||||
const Avatar: FC<AvatarProps> = ({ iconSize = 32, className, avatarId }) => {
|
||||
|
115
web/src/feature/FoldableRolesTable/index.tsx
Normal file
115
web/src/feature/FoldableRolesTable/index.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useRoles } from "@/store/admin/rolesGroups";
|
||||
import { useUsers } from "@/store/admin/users";
|
||||
import type { AppRole } from "@/types";
|
||||
import { ChevronDown, LoaderCircle, Plus } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
|
||||
type Props = {
|
||||
userRoles: AppRole[];
|
||||
onToggleRole?: (scope: string, role: string, isAssigned: boolean) => void;
|
||||
};
|
||||
|
||||
const FoldableRolesTable: React.FC<Props> = ({ userRoles, onToggleRole }) => {
|
||||
const [openScopes, setOpenScopes] = useState<Record<string, boolean>>({});
|
||||
|
||||
const roleMap = useRoles((s) => s.roles);
|
||||
const loadRoles = useRoles((s) => s.fetch);
|
||||
|
||||
const loadUserRoles = useUsers((state) => state.fetchUserRoles);
|
||||
|
||||
const user = useUsers((s) => s.current);
|
||||
|
||||
const togglingRole = useRoles((s) => s.toggling);
|
||||
const toggleRole = useRoles((s) => s.assign);
|
||||
|
||||
const toggleUserRole = useCallback(
|
||||
async (role: AppRole) => {
|
||||
if (togglingRole === role.id) return;
|
||||
|
||||
if (user) {
|
||||
await toggleRole(user.id, role);
|
||||
loadRoles();
|
||||
loadUserRoles();
|
||||
}
|
||||
},
|
||||
[loadRoles, loadUserRoles, toggleRole, togglingRole, user],
|
||||
);
|
||||
|
||||
const toggleScope = (scope: string) => {
|
||||
setOpenScopes((prev) => ({
|
||||
...prev,
|
||||
[scope]: !prev[scope],
|
||||
}));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadRoles();
|
||||
}, [loadRoles]);
|
||||
|
||||
return (
|
||||
<div className="w-full mx-auto shadow-md overflow-hidden">
|
||||
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{Object.entries(roleMap).map(([scope, roles]) => (
|
||||
<div key={`${scope}`}>
|
||||
<div
|
||||
className="w-full text-left px-4 py-3 flex items-center justify-between cursor-pointer"
|
||||
onClick={() => toggleScope(scope)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<span className="font-semibold text-gray-800 dark:text-gray-200">
|
||||
{scope.toUpperCase()}{" "}
|
||||
<span className="text-sm opacity-50 font-light">
|
||||
(
|
||||
{
|
||||
roles.filter((r) =>
|
||||
userRoles.some((ur) => ur.id === r.id),
|
||||
).length
|
||||
}
|
||||
/{roles.length})
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">
|
||||
<ChevronDown
|
||||
className={`${openScopes[scope] ? "rotate-180" : ""} transition`}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul
|
||||
className={`px-5 py-1 pb-4 transition-height ${openScopes[scope] ? "h-auto" : "h-0 py-0!"} overflow-hidden flex items-center flex-wrap gap-2`}
|
||||
>
|
||||
{roles.length === 0 ? (
|
||||
<li className="text-gray-500 italic">No roles found</li>
|
||||
) : (
|
||||
roles.map((role) => (
|
||||
<li
|
||||
key={role.id}
|
||||
className="flex items-center justify-between"
|
||||
onClick={() => toggleUserRole(role)}
|
||||
>
|
||||
<span
|
||||
className={`px-2 py-1 flex items-center gap-1 cursor-pointer select-none rounded text-xs ${
|
||||
userRoles.some((ur) => ur.id === role.id)
|
||||
? "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"
|
||||
} ${togglingRole === role.id ? "opacity-50" : ""}`}
|
||||
>
|
||||
{togglingRole === role.id && (
|
||||
<LoaderCircle className="animate-spin" size={12} />
|
||||
)}
|
||||
{role.name.toUpperCase()}
|
||||
</span>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FoldableRolesTable;
|
67
web/src/feature/RoleMatrix/index.tsx
Normal file
67
web/src/feature/RoleMatrix/index.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import React from "react";
|
||||
|
||||
type RoleMatrixProps = {
|
||||
scopes: string[];
|
||||
roles: string[];
|
||||
userRoles: Record<string, string[]>; // e.g. { "Project Alpha": ["admin", "viewer"] }
|
||||
onToggle?: (scope: string, role: string, isAssigned: boolean) => void;
|
||||
};
|
||||
|
||||
const RoleMatrix: React.FC<RoleMatrixProps> = ({
|
||||
scopes,
|
||||
roles,
|
||||
userRoles,
|
||||
onToggle,
|
||||
}) => {
|
||||
const isChecked = (scope: string, role: string) =>
|
||||
userRoles[scope]?.includes(role) ?? false;
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full table-auto border border-gray-300">
|
||||
<thead className="bg-gray-100">
|
||||
<tr>
|
||||
<th className="border border-gray-300 px-4 py-2 text-left">
|
||||
Scope / Role
|
||||
</th>
|
||||
{roles.map((role) => (
|
||||
<th
|
||||
key={role}
|
||||
className="border border-gray-300 px-4 py-2 text-center capitalize"
|
||||
>
|
||||
{role}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{scopes.map((scope) => (
|
||||
<tr key={scope} className="hover:bg-gray-50">
|
||||
<td className="border border-gray-300 px-4 py-2 font-medium">
|
||||
{scope}
|
||||
</td>
|
||||
{roles.map((role) => {
|
||||
const checked = isChecked(scope, role);
|
||||
return (
|
||||
<td
|
||||
key={`${scope}-${role}`}
|
||||
className="border border-gray-300 px-4 py-2 text-center"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-checkbox h-4 w-4 text-indigo-600 transition duration-150"
|
||||
checked={checked}
|
||||
onChange={() => onToggle?.(scope, role, !checked)}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoleMatrix;
|
@ -1,21 +1,40 @@
|
||||
import { useAuth } from "@/store/auth";
|
||||
import { Blocks, Home, Settings2, User, Users } from "lucide-react";
|
||||
import {
|
||||
Blocks,
|
||||
ContactRound,
|
||||
EarthLock,
|
||||
Home,
|
||||
KeyRound,
|
||||
User,
|
||||
UserLock,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useCallback, type ReactNode } from "react";
|
||||
import { useLocation } from "react-router";
|
||||
|
||||
export interface BarDelimiter {
|
||||
type: "delimiter";
|
||||
key: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface BarItem {
|
||||
type?: "nav";
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
tab: string;
|
||||
pathname: string;
|
||||
}
|
||||
|
||||
export const useBarItems = (): [BarItem[], (item: BarItem) => boolean] => {
|
||||
export type Item = BarItem | BarDelimiter;
|
||||
|
||||
export const useBarItems = (): [Item[], (item: Item) => boolean] => {
|
||||
const profile = useAuth((state) => state.profile);
|
||||
const location = useLocation();
|
||||
|
||||
const isActive = useCallback(
|
||||
(item: BarItem) => {
|
||||
(item: Item) => {
|
||||
if (item.type === "delimiter") return false;
|
||||
if (item.pathname === "/") return location.pathname === item.pathname;
|
||||
return location.pathname.startsWith(item.pathname);
|
||||
},
|
||||
@ -28,6 +47,11 @@ export const useBarItems = (): [BarItem[], (item: BarItem) => boolean] => {
|
||||
|
||||
return [
|
||||
[
|
||||
{
|
||||
type: "delimiter" as const,
|
||||
title: "Basic",
|
||||
key: "basic-del",
|
||||
},
|
||||
{
|
||||
icon: <Home />,
|
||||
title: "Home",
|
||||
@ -40,14 +64,20 @@ export const useBarItems = (): [BarItem[], (item: BarItem) => boolean] => {
|
||||
tab: "personal-info",
|
||||
pathname: "/personal-info",
|
||||
},
|
||||
{
|
||||
icon: <Settings2 />,
|
||||
title: "Data & Personalization",
|
||||
tab: "data-personalization",
|
||||
pathname: "/data-personalize",
|
||||
},
|
||||
// TODO:
|
||||
// {
|
||||
// icon: <Settings2 />,
|
||||
// title: "Data & Personalization",
|
||||
// tab: "data-personalization",
|
||||
// pathname: "/data-personalize",
|
||||
// },
|
||||
...(profile.is_admin
|
||||
? [
|
||||
{
|
||||
type: "delimiter" as const,
|
||||
title: "Admin",
|
||||
key: "admin-del",
|
||||
},
|
||||
{
|
||||
icon: <Blocks />,
|
||||
title: "API Services",
|
||||
@ -60,6 +90,30 @@ export const useBarItems = (): [BarItem[], (item: BarItem) => boolean] => {
|
||||
tab: "admin.users",
|
||||
pathname: "/admin/users",
|
||||
},
|
||||
{
|
||||
icon: <UserLock />,
|
||||
title: "User Sessions",
|
||||
tab: "admin.user-sessions",
|
||||
pathname: "/admin/user-sessions",
|
||||
},
|
||||
{
|
||||
icon: <EarthLock />,
|
||||
title: "Service Sessions",
|
||||
tab: "admin.service-sessions",
|
||||
pathname: "/admin/service-sessions",
|
||||
},
|
||||
{
|
||||
icon: <KeyRound />,
|
||||
title: "App Permissions",
|
||||
tab: "admin.app-permissions",
|
||||
pathname: "/admin/app-permissions",
|
||||
},
|
||||
{
|
||||
icon: <ContactRound />,
|
||||
title: "Roles & Groups",
|
||||
tab: "admin.roles-groups",
|
||||
pathname: "/admin/roles-groups",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
|
@ -9,6 +9,11 @@
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.transition-height {
|
||||
transition-property: height;
|
||||
transition-duration: 300ms;
|
||||
transition-timing-function: ease;
|
||||
}
|
||||
}
|
||||
|
||||
html,
|
||||
|
@ -31,6 +31,8 @@ const AuthLayout = () => {
|
||||
const authenticate = useAuth((state) => state.authenticate);
|
||||
const hasAuthenticated = useAuth((state) => state.hasAuthenticated);
|
||||
|
||||
const authProfile = useAuth((s) => s.profile);
|
||||
|
||||
const signInRequired = useAuth((state) => state.signInRequired);
|
||||
|
||||
const location = useLocation();
|
||||
@ -61,6 +63,18 @@ const AuthLayout = () => {
|
||||
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(
|
||||
@ -136,6 +150,14 @@ const AuthLayout = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!signInRequired &&
|
||||
verificationRequired &&
|
||||
!location.pathname.startsWith("/verify")
|
||||
) {
|
||||
return <Navigate to="/verify" state={{ from: location.pathname }} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<BackgroundLayout>
|
||||
<Outlet />
|
||||
|
@ -6,8 +6,9 @@ export interface IBackgroundLayoutProps {
|
||||
|
||||
const BackgroundLayout: FC<IBackgroundLayoutProps> = ({ children }) => {
|
||||
return (
|
||||
// <div className="relative min-h-screen bg-cover bg-center bg-white dark:bg-black bg-[url(/overlay.jpg)] dark:bg-[url(/dark-overlay.jpg)]">
|
||||
<div className="relative min-h-screen bg-[url(/overlay.jpg)] bg-[#f8f9fb] dark:bg-gradient-to-br from-[#101112] to-[#041758]">
|
||||
// <div className="relative min-h-screen bg-[url(/overlay.jpg)] bg-[#f8f9fb] dark:bg-gradient-to-br from-[#101112] to-[#041758]">
|
||||
// <div className="relative min-h-screen bg-cover bg-center bg-[url(/overlay.jpg)] bg-[#f8f9fb] dark:bg-[#101112] dark:bg-[url(/background-dark.png)]">
|
||||
<div className="relative min-h-screen bg-cover bg-center bg-[#f8f9fb] dark:bg-black bg-[url(/overlay.jpg)] dark:bg-[url(/dark-overlay.jpg)]">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
@ -7,9 +7,9 @@ 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 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 relative">
|
||||
<div className="flex flex-row items-center absolute left-4 top-4">
|
||||
<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">
|
||||
@ -20,12 +20,12 @@ const DashboardLayout: FC = () => {
|
||||
</div>
|
||||
|
||||
<CardContent
|
||||
className="w-full h-full max-h-full space-y-4 flex-1 bg-black/5 dark:bg-white/5"
|
||||
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:max-h-screen overflow-y-auto">
|
||||
<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>
|
||||
|
86
web/src/layout/VerificationLayout.tsx
Normal file
86
web/src/layout/VerificationLayout.tsx
Normal 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;
|
@ -33,9 +33,9 @@ const ApiServiceCreatePage: FC = () => {
|
||||
const credentials = useApiServices((state) => state.createdCredentials);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(data: FormData) => {
|
||||
async (data: FormData) => {
|
||||
console.log("Form submitted:", data);
|
||||
createApiService({
|
||||
await createApiService({
|
||||
name: data.name,
|
||||
description: data.description ?? "",
|
||||
redirect_uris: data.redirectUris.trim().split("\n"),
|
||||
@ -45,6 +45,9 @@ const ApiServiceCreatePage: FC = () => {
|
||||
: ["authorization_code"],
|
||||
is_active: data.enabled,
|
||||
});
|
||||
// if (success) {
|
||||
// navigate("/admin/api-services");
|
||||
// }
|
||||
},
|
||||
[createApiService],
|
||||
);
|
||||
|
@ -15,7 +15,7 @@ const ApiServicesPage: FC = () => {
|
||||
}, [fetchApiServices]);
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col items-stretch w-full h-full">
|
||||
<div className="relative flex flex-col items-stretch w-full">
|
||||
<div className="p-4">
|
||||
<Breadcrumbs
|
||||
className="pb-2"
|
||||
|
130
web/src/pages/Admin/AppPermissions/index.tsx
Normal file
130
web/src/pages/Admin/AppPermissions/index.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
import { useEffect, type FC } from "react";
|
||||
import Breadcrumbs from "@/components/ui/breadcrumbs";
|
||||
import { usePermissions } from "@/store/admin/permissions";
|
||||
|
||||
interface DisplayPermission {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface IPermissionGroupProps {
|
||||
scope: string;
|
||||
permissions?: DisplayPermission[] | null | undefined;
|
||||
generatedPermissions?: DisplayPermission[] | null | undefined;
|
||||
}
|
||||
|
||||
const PermissionGroup: FC<IPermissionGroupProps> = ({
|
||||
scope,
|
||||
permissions,
|
||||
generatedPermissions,
|
||||
}) => {
|
||||
return (
|
||||
<div className="border dark:border-gray-800 border-gray-300 p-4 rounded mb-4">
|
||||
<h2 className="dark:text-gray-300 text-gray-800 text-lg font-semibold">
|
||||
{scope}
|
||||
</h2>
|
||||
|
||||
{(generatedPermissions?.length ?? 0) > 0 && (
|
||||
<>
|
||||
<p className="text-gray-500 text-sm mt-2 mb-4">Generated by Guard</p>
|
||||
|
||||
<ol
|
||||
className={`grid gap-4 gap-y-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 text-gray-800 dark:text-gray-300 font-medium mb-${permissions && permissions.length > 0 ? "6" : "2"}`}
|
||||
>
|
||||
{generatedPermissions!.map(({ name, description }) => (
|
||||
<li className="before:w-2 before:h-2 before:block before:translate-y-2 before:bg-gray-400 dark:before:bg-gray-700 before:rounded-xs flex flex-row items-start gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label>{name}</label>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(permissions?.length ?? 0) > 0 && (
|
||||
<>
|
||||
<p className="text-gray-500 text-sm mt-2 mb-4">Manually Created</p>
|
||||
|
||||
<ol className="grid gap-4 gap-y-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 text-gray-800 dark:text-gray-300 font-medium">
|
||||
{permissions!.map(({ name, description }) => (
|
||||
<li className="before:w-2 before:h-2 before:block before:translate-y-2 before:bg-gray-400 dark:before:bg-gray-700 before:rounded-xs flex flex-row items-start gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label>{name}</label>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AdminAppPermissionsPage: FC = () => {
|
||||
const permissions = usePermissions((s) => s.permissions);
|
||||
const fetch = usePermissions((s) => s.fetch);
|
||||
|
||||
useEffect(() => {
|
||||
fetch();
|
||||
}, [fetch]);
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col items-stretch w-full">
|
||||
<div className="p-4">
|
||||
<Breadcrumbs
|
||||
className="pb-2"
|
||||
items={[
|
||||
{
|
||||
href: "/admin",
|
||||
label: "Admin",
|
||||
},
|
||||
{
|
||||
label: "App Permissions",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="px-7">
|
||||
{Object.keys(permissions).map((scope) => (
|
||||
<PermissionGroup
|
||||
scope={scope.toUpperCase()}
|
||||
generatedPermissions={permissions[scope].map((p) => ({
|
||||
name: p.name
|
||||
.split("_")
|
||||
.map((s) => s[0].toUpperCase() + s.slice(1))
|
||||
.join(" "),
|
||||
description: p.description,
|
||||
}))}
|
||||
/>
|
||||
))}
|
||||
{/* <PermissionGroup
|
||||
scope="Open Chat"
|
||||
generatedPermissions={["Access Open Chat"].map((name) => ({
|
||||
name,
|
||||
description: `You can ${name.toLowerCase()}`,
|
||||
}))}
|
||||
permissions={[
|
||||
"Receive Messages",
|
||||
"Send Messages",
|
||||
"View Status",
|
||||
"Post Status",
|
||||
"Use Ghost Mode",
|
||||
"Send Large Media",
|
||||
].map((name) => ({
|
||||
name,
|
||||
description: `You can ${name.toLowerCase()}`,
|
||||
}))}
|
||||
/> */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminAppPermissionsPage;
|
91
web/src/pages/Admin/RolesGroups/index.tsx
Normal file
91
web/src/pages/Admin/RolesGroups/index.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
import { useEffect, type FC } from "react";
|
||||
import Breadcrumbs from "@/components/ui/breadcrumbs";
|
||||
import { useRoles } from "@/store/admin/rolesGroups";
|
||||
import type { AppPermission } from "@/types";
|
||||
|
||||
interface DisplayRole {
|
||||
name: string;
|
||||
description: string;
|
||||
permissions: AppPermission[];
|
||||
}
|
||||
|
||||
interface IPermissionGroupProps {
|
||||
scope: string;
|
||||
roles?: DisplayRole[] | null | undefined;
|
||||
}
|
||||
|
||||
const RolesGroup: FC<IPermissionGroupProps> = ({ scope, roles }) => {
|
||||
return (
|
||||
<div className="border dark:border-gray-800 border-gray-300 p-4 rounded mb-4">
|
||||
<h2 className="dark:text-gray-300 text-gray-800 text-lg font-semibold mb-4">
|
||||
{scope.toUpperCase()} <span className="opacity-45">(scope)</span>
|
||||
</h2>
|
||||
|
||||
{roles?.map((role, index) => (
|
||||
<div>
|
||||
<h2 className="dark:text-gray-300 text-gray-800 text-md font-semibold">
|
||||
{role.name.toUpperCase()} <span className="opacity-45">(role)</span>
|
||||
</h2>
|
||||
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500">
|
||||
{role.description}
|
||||
</p>
|
||||
|
||||
<ol className="grid gap-4 gap-y-3 grid-cols-1 my-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 text-gray-800 dark:text-gray-300 font-medium">
|
||||
{role.permissions.map(({ name, description }) => (
|
||||
<li className="before:w-2 before:h-2 before:block before:translate-y-2 before:bg-gray-400 dark:before:bg-gray-700 before:rounded-xs flex flex-row items-start gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label>{name}</label>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{index + 1 < roles.length && (
|
||||
<div className="h-[1px] bg-gray-700 w-full rounded my-6"></div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AdminRolesGroupsPage: FC = () => {
|
||||
const roles = useRoles((s) => s.roles);
|
||||
const fetch = useRoles((s) => s.fetch);
|
||||
|
||||
useEffect(() => {
|
||||
fetch();
|
||||
}, [fetch]);
|
||||
|
||||
console.log("roles:", roles);
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col items-stretch w-full">
|
||||
<div className="p-4">
|
||||
<Breadcrumbs
|
||||
className="pb-2"
|
||||
items={[
|
||||
{
|
||||
href: "/admin",
|
||||
label: "Admin",
|
||||
},
|
||||
{
|
||||
label: "Roles & Groups",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="px-7">
|
||||
{Object.keys(roles).map((scope) => (
|
||||
<RolesGroup scope={scope} roles={roles[scope]} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminRolesGroupsPage;
|
214
web/src/pages/Admin/ServiceSessions/index.tsx
Normal file
214
web/src/pages/Admin/ServiceSessions/index.tsx
Normal file
@ -0,0 +1,214 @@
|
||||
import Breadcrumbs from "@/components/ui/breadcrumbs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Avatar from "@/feature/Avatar";
|
||||
import { Ban } from "lucide-react";
|
||||
import { useCallback, useEffect, type FC } from "react";
|
||||
import { Link } from "react-router";
|
||||
import moment from "moment";
|
||||
import Pagination from "@/components/ui/pagination";
|
||||
import { useAuth } from "@/store/auth";
|
||||
import { useServiceSessions } from "@/store/admin/serviceSessions";
|
||||
|
||||
const AdminServiceSessionsPage: FC = () => {
|
||||
const loading = useServiceSessions((s) => s.loading);
|
||||
const sessions = useServiceSessions((s) => s.items);
|
||||
|
||||
const page = useServiceSessions((s) => s.page);
|
||||
const totalPages = useServiceSessions((s) => s.totalPages);
|
||||
|
||||
const fetchSessions = useServiceSessions((s) => s.fetch);
|
||||
const revokeSession = useServiceSessions((s) => s.revoke);
|
||||
|
||||
const revokingId = useServiceSessions((s) => s.revokingId);
|
||||
|
||||
const profile = useAuth((s) => s.profile);
|
||||
|
||||
const handleRevokeSession = useCallback(
|
||||
(id: string) => {
|
||||
revokeSession(id);
|
||||
},
|
||||
[revokeSession],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSessions(1);
|
||||
}, [fetchSessions]);
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col items-stretch w-full">
|
||||
<div className="p-4">
|
||||
<Breadcrumbs
|
||||
className="pb-2"
|
||||
items={[
|
||||
{
|
||||
href: "/admin",
|
||||
label: "Admin",
|
||||
},
|
||||
{
|
||||
label: "Service Sessions",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4 flex flex-row items-center justify-between">
|
||||
<p className="text-gray-800 dark:text-gray-300">Search...</p>
|
||||
{/* TODO: Filters */}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-x-auto">
|
||||
<table className="relative min-w-full border-l-0 border border-gray-300 dark:border-gray-700 border-collapse divide-y divide-gray-200 dark:divide-gray-800">
|
||||
{loading && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/60 dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<div className="text-gray-800 dark:text-gray-200 font-medium">
|
||||
Loading...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<thead className="bg-black/5 dark:bg-white/5 text-nowrap">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-300 dark:border-gray-700">
|
||||
Service
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-300 dark:border-gray-700">
|
||||
User + IP
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-300 dark:border-gray-700">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-300 dark:border-gray-700">
|
||||
Issued At
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-300 dark:border-gray-700">
|
||||
Expires At
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-300 dark:border-gray-700">
|
||||
Last Active
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-300 dark:border-gray-700">
|
||||
Revoked At
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-300 dark:border-gray-700">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{!loading && sessions.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={5}
|
||||
className="px-6 py-12 text-center text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
No sessions found.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
sessions.map((session) => (
|
||||
<tr
|
||||
key={session.id}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
>
|
||||
<td className="px-5 py-3 text-sm text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-700">
|
||||
{/* <SessionSource deviceInfo={session.} /> */}
|
||||
{typeof session.api_service?.icon_url === "string" && (
|
||||
<Avatar
|
||||
avatarId={session.api_service.icon_url ?? null}
|
||||
className="w-7 h-7 min-w-7"
|
||||
/>
|
||||
)}
|
||||
<Link to={`/admin/api-services/view/${session.service_id}`}>
|
||||
<p className="cursor-pointer text-blue-500 text-nowrap">
|
||||
{session.api_service?.name ?? session.client_id}
|
||||
</p>
|
||||
</Link>
|
||||
</td>
|
||||
|
||||
<td className="px-5 py-3 text-sm text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-700">
|
||||
<div className="flex flex-col items-stretch gap-2 justify-start">
|
||||
<div className="flex flex-row items-center gap-2 justify-start">
|
||||
{typeof session.user?.profile_picture === "string" && (
|
||||
<Avatar
|
||||
avatarId={session.user.profile_picture ?? null}
|
||||
className="w-7 h-7 min-w-7"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col items-stretch justify-center">
|
||||
<Link to={`/admin/users/view/${session.user_id}`}>
|
||||
<p className="cursor-pointer text-blue-500 text-nowrap">
|
||||
{session.user?.full_name ?? ""}{" "}
|
||||
{session.user_id === profile?.id ? "(You)" : ""}
|
||||
</p>
|
||||
</Link>
|
||||
<p className="opacity-75">
|
||||
{session.ip_address ?? "No IP available"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-sm border border-gray-300 dark:border-gray-700">
|
||||
<span
|
||||
className={`inline-block px-2 py-1 text-xs rounded-full font-semibold ${
|
||||
!session.is_active ||
|
||||
(session.expires_at &&
|
||||
moment(session.expires_at).isSameOrBefore(
|
||||
moment(new Date()),
|
||||
))
|
||||
? "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300"
|
||||
: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300"
|
||||
}`}
|
||||
>
|
||||
{session.is_active ? "Active" : "Inactive"}
|
||||
{moment(session.expires_at).isSameOrBefore(
|
||||
moment(new Date()),
|
||||
) && " (Expired)"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-sm text-gray-500 dark:text-gray-400 border border-gray-300 dark:border-gray-700">
|
||||
{moment(session.issued_at).format("LLLL")}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-sm text-gray-500 dark:text-gray-400 border border-gray-300 dark:border-gray-700">
|
||||
{session.expires_at
|
||||
? moment(session.expires_at).format("LLLL")
|
||||
: "never"}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-sm text-gray-500 dark:text-gray-400 border border-gray-300 dark:border-gray-700">
|
||||
{session.last_active
|
||||
? moment(session.last_active).format("LLLL")
|
||||
: "never"}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-sm text-gray-500 dark:text-gray-400 border border-gray-300 dark:border-gray-700">
|
||||
{session.revoked_at
|
||||
? new Date(session.revoked_at).toLocaleString()
|
||||
: "never"}
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex flex-row items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="contained"
|
||||
className="bg-red-500 hover:bg-red-600 !px-1.5 !py-1.5"
|
||||
onClick={() => handleRevokeSession(session.id)}
|
||||
disabled={revokingId === session.id}
|
||||
>
|
||||
<Ban size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
onPageChange={(newPage) => fetchSessions(newPage)}
|
||||
totalPages={totalPages}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminServiceSessionsPage;
|
209
web/src/pages/Admin/UserSessions/index.tsx
Normal file
209
web/src/pages/Admin/UserSessions/index.tsx
Normal file
@ -0,0 +1,209 @@
|
||||
import Breadcrumbs from "@/components/ui/breadcrumbs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Avatar from "@/feature/Avatar";
|
||||
import type { DeviceInfo } from "@/types";
|
||||
import { Ban } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, type FC } from "react";
|
||||
import { Link } from "react-router";
|
||||
import moment from "moment";
|
||||
import Pagination from "@/components/ui/pagination";
|
||||
import { useUserSessions } from "@/store/admin/userSessions";
|
||||
import { useAuth } from "@/store/auth";
|
||||
|
||||
const SessionSource: FC<{ deviceInfo: string }> = ({ deviceInfo }) => {
|
||||
const parsed = useMemo<DeviceInfo>(
|
||||
() => JSON.parse(atob(deviceInfo)),
|
||||
[deviceInfo],
|
||||
);
|
||||
|
||||
return (
|
||||
<p>
|
||||
{parsed.os} {parsed.os_version} {parsed.browser} {parsed.browser_version}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
const AdminUserSessionsPage: FC = () => {
|
||||
const loading = useUserSessions((s) => s.loading);
|
||||
const sessions = useUserSessions((s) => s.items);
|
||||
|
||||
const page = useUserSessions((s) => s.page);
|
||||
const totalPages = useUserSessions((s) => s.totalPages);
|
||||
|
||||
const fetchSessions = useUserSessions((s) => s.fetch);
|
||||
const revokeSession = useUserSessions((s) => s.revoke);
|
||||
|
||||
const revokingId = useUserSessions((s) => s.revokingId);
|
||||
|
||||
const profile = useAuth((s) => s.profile);
|
||||
|
||||
const handleRevokeSession = useCallback(
|
||||
(id: string) => {
|
||||
revokeSession(id);
|
||||
},
|
||||
[revokeSession],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSessions(1);
|
||||
}, [fetchSessions]);
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col items-stretch w-full">
|
||||
<div className="p-4">
|
||||
<Breadcrumbs
|
||||
className="pb-2"
|
||||
items={[
|
||||
{
|
||||
href: "/admin",
|
||||
label: "Admin",
|
||||
},
|
||||
{
|
||||
label: "User Sessions",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4 flex flex-row items-center justify-between">
|
||||
<p className="text-gray-800 dark:text-gray-300">Search...</p>
|
||||
{/* TODO: Filters */}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-x-auto">
|
||||
<table className="relative min-w-full border-l-0 border border-gray-300 dark:border-gray-700 border-collapse divide-y divide-gray-200 dark:divide-gray-800">
|
||||
{loading && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/60 dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<div className="text-gray-800 dark:text-gray-200 font-medium">
|
||||
Loading...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<thead className="bg-black/5 dark:bg-white/5 text-nowrap">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-300 dark:border-gray-700">
|
||||
User
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-300 dark:border-gray-700">
|
||||
Source
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-300 dark:border-gray-700">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-300 dark:border-gray-700">
|
||||
Issued At
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-300 dark:border-gray-700">
|
||||
Expires At
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-300 dark:border-gray-700">
|
||||
Last Active
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-300 dark:border-gray-700">
|
||||
Revoked At
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-300 dark:border-gray-700">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{!loading && sessions.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={5}
|
||||
className="px-6 py-12 text-center text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
No sessions found.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
sessions.map((session) => (
|
||||
<tr
|
||||
key={session.id}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
>
|
||||
<td className="px-5 py-3 text-sm text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-700">
|
||||
<div className="flex flex-row items-center gap-2 justify-start">
|
||||
{typeof session.user?.profile_picture === "string" && (
|
||||
<Avatar
|
||||
avatarId={session.user.profile_picture ?? null}
|
||||
className="w-7 h-7 min-w-7"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Link to={`/admin/users/view/${session.user_id}`}>
|
||||
<p className="cursor-pointer text-blue-500 text-nowrap">
|
||||
{session.user?.full_name ?? ""}{" "}
|
||||
{session.user_id === profile?.id ? "(You)" : ""}
|
||||
</p>
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-sm text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-700">
|
||||
<SessionSource deviceInfo={session.device_info} />
|
||||
</td>
|
||||
<td className="px-5 py-3 text-sm border border-gray-300 dark:border-gray-700">
|
||||
<span
|
||||
className={`inline-block px-2 py-1 text-xs rounded-full font-semibold ${
|
||||
!session.is_active ||
|
||||
(session.expires_at &&
|
||||
moment(session.expires_at).isSameOrBefore(
|
||||
moment(new Date()),
|
||||
))
|
||||
? "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300"
|
||||
: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300"
|
||||
}`}
|
||||
>
|
||||
{session.is_active ? "Active" : "Inactive"}
|
||||
{moment(session.expires_at).isSameOrBefore(
|
||||
moment(new Date()),
|
||||
) && " (Expired)"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-sm text-gray-500 dark:text-gray-400 border border-gray-300 dark:border-gray-700">
|
||||
{moment(session.issued_at).format("LLLL")}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-sm text-gray-500 dark:text-gray-400 border border-gray-300 dark:border-gray-700">
|
||||
{session.expires_at
|
||||
? moment(session.expires_at).format("LLLL")
|
||||
: "never"}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-sm text-gray-500 dark:text-gray-400 border border-gray-300 dark:border-gray-700">
|
||||
{session.last_active
|
||||
? moment(session.last_active).format("LLLL")
|
||||
: "never"}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-sm text-gray-500 dark:text-gray-400 border border-gray-300 dark:border-gray-700">
|
||||
{session.revoked_at
|
||||
? new Date(session.revoked_at).toLocaleString()
|
||||
: "never"}
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex flex-row items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="contained"
|
||||
className="bg-red-500 hover:bg-red-600 !px-1.5 !py-1.5"
|
||||
onClick={() => handleRevokeSession(session.id)}
|
||||
disabled={revokingId === session.id}
|
||||
>
|
||||
<Ban size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
onPageChange={(newPage) => fetchSessions(newPage)}
|
||||
totalPages={totalPages}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminUserSessionsPage;
|
@ -4,7 +4,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { useUsers } from "@/store/admin/users";
|
||||
import { useCallback, type FC } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Link } from "react-router";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
|
||||
interface FormData {
|
||||
fullName: string;
|
||||
@ -24,17 +24,22 @@ const AdminCreateUserPage: FC = () => {
|
||||
|
||||
const createUser = useUsers((state) => state.createUser);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(data: FormData) => {
|
||||
async (data: FormData) => {
|
||||
console.log("Form submitted:", data);
|
||||
createUser({
|
||||
const success = await createUser({
|
||||
email: data.email,
|
||||
full_name: data.fullName,
|
||||
password: data.password,
|
||||
is_admin: data.isAdmin,
|
||||
});
|
||||
if (success) {
|
||||
navigate("/admin/users");
|
||||
}
|
||||
},
|
||||
[createUser],
|
||||
[createUser, navigate],
|
||||
);
|
||||
|
||||
return (
|
||||
|
@ -1,38 +1,65 @@
|
||||
import Breadcrumbs from "@/components/ui/breadcrumbs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Avatar from "@/feature/Avatar";
|
||||
import FoldableRolesTable from "@/feature/FoldableRolesTable";
|
||||
import RoleMatrix from "@/feature/RoleMatrix";
|
||||
import { useRoles } from "@/store/admin/rolesGroups";
|
||||
import { useUsers } from "@/store/admin/users";
|
||||
import { useEffect, type FC } from "react";
|
||||
import type { UserProfile } from "@/types";
|
||||
import { Check, Mail, Phone } from "lucide-react";
|
||||
import moment from "moment";
|
||||
import { useEffect, type FC, type HTMLAttributes } from "react";
|
||||
import { Link, useParams } from "react-router";
|
||||
|
||||
const InfoCard = ({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
interface InfoCardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
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">
|
||||
hasSpacing?: boolean | undefined;
|
||||
}
|
||||
|
||||
const InfoCard = ({
|
||||
children,
|
||||
hasSpacing = true,
|
||||
title,
|
||||
...props
|
||||
}: InfoCardProps) => (
|
||||
<div className="mb-6">
|
||||
<h2 className="mb-4 text-xl">{title}</h2>
|
||||
<div
|
||||
{...props}
|
||||
className={`border dark:border-gray-800 border-gray-300 rounded mb-4 ${props.className}`}
|
||||
>
|
||||
{/* <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={hasSpacing ? "p-4" : ""}>{children}</div>
|
||||
</div>
|
||||
<div className="p-4">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const AdminViewUserPage: FC = () => {
|
||||
const { userId } = useParams();
|
||||
const user = useUsers((state) => state.current);
|
||||
|
||||
const userRoles = useUsers((s) => s.userRoles);
|
||||
// const loading = useApiServices((state) => state.fetchingApiService);
|
||||
|
||||
const loadUser = useUsers((state) => state.fetchUser);
|
||||
|
||||
const loadUserRoles = useUsers((state) => state.fetchUserRoles);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof userId === "string") loadUser(userId);
|
||||
}, [loadUser, userId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) loadUserRoles();
|
||||
}, [loadUserRoles, user]);
|
||||
|
||||
console.log({ userRoles });
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="p-4 flex items-center justify-center h-[60vh]">
|
||||
@ -55,70 +82,138 @@ const AdminViewUserPage: FC = () => {
|
||||
/>
|
||||
|
||||
<div className="sm:p-4 pt-4">
|
||||
{/* 📋 Main Details */}
|
||||
<InfoCard title="Personal Info">
|
||||
<InfoCard title="Profile" hasSpacing={false}>
|
||||
<div className="flex flex-col gap-4 text-sm">
|
||||
<div className="flex flex-col gap-4">
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
Avatar:
|
||||
</span>
|
||||
<div className="bg-black/15 shadow/20">
|
||||
{/* Header */}
|
||||
<div className="flex flex-row gap-4 items-center p-4">
|
||||
<Avatar
|
||||
avatarId={user.profile_picture ?? undefined}
|
||||
className="w-16 h-16"
|
||||
iconSize={28}
|
||||
avatarId={user.profile_picture ?? null}
|
||||
className="w-28 h-28"
|
||||
iconSize={48}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
Full Name:
|
||||
</span>{" "}
|
||||
{user.full_name}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
Email:
|
||||
</span>{" "}
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-lg font-medium">{user.full_name}</h2>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<h3 className="text-base opacity-50 flex flex-row items-center gap-2">
|
||||
<span>
|
||||
<Mail size={18} />
|
||||
</span>
|
||||
{user.email}
|
||||
</h3>
|
||||
<div className="w-1 h-1 rounded-full bg-gray-600" />
|
||||
<h3 className="text-base opacity-50 flex flex-row items-center gap-2">
|
||||
<span>
|
||||
<Phone size={18} />
|
||||
</span>
|
||||
{user.phone_number || "No Phone Number"}
|
||||
</h3>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
Phone Number:
|
||||
</span>{" "}
|
||||
{user.phone_number || "-"}{" "}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
Is Admin:
|
||||
</div>
|
||||
{/* TODO: */}
|
||||
{/* Top Bars */}
|
||||
{/* <div className="w-full border-b border-b-gray-600 p-4">
|
||||
somethign
|
||||
</div> */}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 gap-y-4 p-4">
|
||||
<div className="flex flex-col gap-2 items-start">
|
||||
<span className="font-medium text-gray-400 dark:text-gray-500">
|
||||
Verification
|
||||
</span>{" "}
|
||||
<div className="flex items-center gap-2">
|
||||
{[
|
||||
["avatar_verified", "Avatar"],
|
||||
["email_verified", "Email"],
|
||||
].map(([key, label]) => (
|
||||
<span
|
||||
className={`font-semibold px-2 py-1 rounded ${
|
||||
user.is_admin
|
||||
key={key}
|
||||
className={`px-2 py-1 rounded ${
|
||||
user[key as keyof UserProfile] === true
|
||||
? "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"
|
||||
}`}
|
||||
>
|
||||
{user.is_admin ? "Yes" : "No"}
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 items-start">
|
||||
<span className="font-medium text-gray-400 dark:text-gray-500">
|
||||
Roles
|
||||
</span>{" "}
|
||||
<span className={`font-semibold py-1 rounded`}>
|
||||
{userRoles.length === 0 && <p>No Roles</p>}
|
||||
{userRoles.map((role) => (
|
||||
<span
|
||||
key={role.id}
|
||||
className="px-2 py-1 rounded bg-blue-200 text-blue-800 dark:bg-blue-700/20 dark:text-blue-300"
|
||||
>
|
||||
{role.name}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
Created At:
|
||||
<div className="flex flex-col gap-2 items-start">
|
||||
<span className="font-medium text-gray-400 dark:text-gray-500">
|
||||
Created At
|
||||
</span>{" "}
|
||||
{new Date(user.created_at).toLocaleString()}
|
||||
<span className={`font-semibold py-1 rounded`}>
|
||||
{moment(user.created_at).format("LLLL")}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
Last Login At:
|
||||
<div className="flex flex-col gap-2 items-start">
|
||||
<span className="font-medium text-gray-400 dark:text-gray-500">
|
||||
Last Login
|
||||
</span>{" "}
|
||||
<span className={`font-semibold py-1 rounded`}>
|
||||
{user.last_login
|
||||
? new Date(user.last_login).toLocaleString()
|
||||
? moment(user.last_login).format("LLLL")
|
||||
: "never"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</InfoCard>
|
||||
|
||||
<InfoCard title="Roles Management" hasSpacing={false}>
|
||||
<FoldableRolesTable userRoles={userRoles} />
|
||||
</InfoCard>
|
||||
|
||||
{/* <InfoCard title="Roles" hasSpacing={false}>
|
||||
{Object.entries(roles).map(([scope, roles]) => (
|
||||
<div className="w-full">
|
||||
<h4 className="bg-black/40 p-3 text-xs opacity-50">
|
||||
{scope.toUpperCase()}
|
||||
</h4>
|
||||
<ul className="bg-black/20">
|
||||
{roles.map((r, index) => (
|
||||
<div key={r.id}>
|
||||
<li
|
||||
key={r.id}
|
||||
className="p-2 px-4 flex flex-row items-center gap-2"
|
||||
>
|
||||
<span
|
||||
className={`opacity-${userRoles.some((ur) => ur.id === r.id) ? "100" : "0"}`}
|
||||
>
|
||||
<Check size={16} />
|
||||
</span>
|
||||
{r.name.toUpperCase()}
|
||||
</li>
|
||||
{index + 1 < roles.length && (
|
||||
<div className="h-[1px] bg-gray-800 w-[95%] mx-auto"></div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</InfoCard> */}
|
||||
|
||||
{/* 🚀 Actions */}
|
||||
<div className="flex flex-wrap gap-4 mt-6 justify-between items-center">
|
||||
<div className="flex flex-wrap gap-4 mt-6 justify-between items-center col-span-3">
|
||||
<Link to="/admin/users">
|
||||
<Button variant="outlined">Back</Button>
|
||||
</Link>
|
||||
|
@ -16,7 +16,7 @@ const AdminUsersPage: FC = () => {
|
||||
}, [fetchUsers]);
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col items-stretch w-full h-full">
|
||||
<div className="relative flex flex-col items-stretch w-full">
|
||||
<div className="p-4">
|
||||
<Breadcrumbs
|
||||
className="pb-2"
|
||||
@ -76,7 +76,7 @@ const AdminUsersPage: FC = () => {
|
||||
colSpan={5}
|
||||
className="px-6 py-12 text-center text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
No services found.
|
||||
No users found.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
@ -94,7 +94,7 @@ const AdminUsersPage: FC = () => {
|
||||
<Avatar
|
||||
iconSize={21}
|
||||
className="w-8 h-8"
|
||||
avatarId={user.profile_picture ?? undefined}
|
||||
avatarId={user.profile_picture ?? null}
|
||||
/>
|
||||
<p>{user.full_name}</p>
|
||||
</div>
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { useCallback, type FC } from "react";
|
||||
import { useCallback, useEffect, type FC } from "react";
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { ArrowLeftRight, ChevronDown } from "lucide-react";
|
||||
import { ArrowLeftRight, ChevronDown, LayoutDashboard } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Avatar from "@/feature/Avatar";
|
||||
import { useAuth } from "@/store/auth";
|
||||
@ -13,6 +13,12 @@ const AuthorizePage: FC = () => {
|
||||
|
||||
const profile = useAuth((state) => state.profile);
|
||||
|
||||
const fetchService = useOAuth((s) => s.fetchApiService);
|
||||
const fetching = useOAuth((s) => s.fetching);
|
||||
const clientId = useOAuth((s) => s.clientID);
|
||||
|
||||
const apiService = useOAuth((s) => s.apiService);
|
||||
|
||||
const selectSession = useOAuth((state) => state.selectSession);
|
||||
|
||||
const handleAgree = useCallback(() => {
|
||||
@ -20,12 +26,44 @@ const AuthorizePage: FC = () => {
|
||||
selectSession(activeAccount.access);
|
||||
}, [activeAccount, selectSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (clientId) {
|
||||
fetchService();
|
||||
}
|
||||
}, [clientId, fetchService]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative min-h-screen bg-cover bg-center bg-white dark:bg-black bg-[url(/overlay.jpg)] dark:bg-[url(/dark-overlay.jpg)]`}
|
||||
>
|
||||
<div className="relative z-10 flex items-center justify-center min-h-screen">
|
||||
<Card className="sm:w-[425px] sm:min-w-[425px] sm:max-w-96 sm:min-h-auto p-3 min-h-screen w-full min-w-full shadow-lg bg-white/65 dark:bg-black/65 backdrop-blur-md">
|
||||
<Card
|
||||
className={`relative sm:w-[425px] sm:min-w-[425px] sm:max-w-96 sm:min-h-auto min-h-screen w-full min-w-full shadow-lg bg-white/65 dark:bg-black/65 backdrop-blur-md ${!fetching ? "p-3" : ""}`}
|
||||
>
|
||||
{fetching && (
|
||||
<div
|
||||
role="status"
|
||||
className="absolute w-full h-full flex-1 flex items-center justify-center bg-black/25 dark:bg-white/25"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-12 h-12 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>
|
||||
)}
|
||||
<div className="flex flex-col items-center pt-10 sm:pt-0">
|
||||
<div className="flex flex-col items-center flex-5/6">
|
||||
{/* <img
|
||||
@ -38,22 +76,27 @@ const AuthorizePage: FC = () => {
|
||||
<div className="text-gray-400 dark:text-gray-600">
|
||||
<ArrowLeftRight />
|
||||
</div>
|
||||
<div className="p-2 rounded-full bg-gray-900 ring ring-gray-400 dark:ring dark:ring-gray-500">
|
||||
{/* <div className="w-12 h-12 rounded-full flex items-center justify-center overflow-hidden bg-gray-900 ring ring-gray-400 dark:ring dark:ring-gray-500"> */}
|
||||
{/* <img
|
||||
src="https://lucide.dev/logo.dark.svg"
|
||||
className="w-8 h-8"
|
||||
/> */}
|
||||
{apiService?.icon_url ? (
|
||||
<img
|
||||
src="https://developer.mozilla.org/favicon.svg"
|
||||
className="w-8 h-8"
|
||||
src={apiService.icon_url}
|
||||
className="w-12 h-12"
|
||||
alt="service_icon"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<LayoutDashboard size={32} color="#fefefe" />
|
||||
)}
|
||||
{/* </div> */}
|
||||
</div>
|
||||
|
||||
<div className="px-4 sm:mt-4 mt-8">
|
||||
<h2 className="text-2xl font-medium text-gray-800 dark:text-gray-300 text-center w-full mb-2">
|
||||
<a href="#" className="text-blue-500">
|
||||
MDN Lab Services
|
||||
{apiService?.name ?? "Service"}
|
||||
</a>{" "}
|
||||
wants to access your Home Account
|
||||
</h2>
|
||||
@ -73,7 +116,7 @@ const AuthorizePage: FC = () => {
|
||||
<h4 className="text-base mb-3 text-gray-400 dark:text-gray-500 text-left">
|
||||
This will allow{" "}
|
||||
<a href="#" className="text-blue-500">
|
||||
MDN Lab Services
|
||||
{apiService?.name ?? "service"}
|
||||
</a>{" "}
|
||||
to:
|
||||
</h4>
|
||||
@ -83,23 +126,26 @@ const AuthorizePage: FC = () => {
|
||||
{/* <LogIn className="w-8 h-8 text-gray-700 mb-4" /> */}
|
||||
<CardContent className="w-full space-y-4 text-sm">
|
||||
<div className="flex flex-col gap-3 mb-8">
|
||||
{(apiService?.scopes?.length ?? 0) > 0 &&
|
||||
apiService!.scopes.map((scope) => (
|
||||
<div className="flex flex-row items-center justify-between text-gray-600 dark:text-gray-400">
|
||||
<div className="flex flex-row items-center gap-4">
|
||||
<div className="w-3 h-3 rounded-full bg-blue-500"></div>
|
||||
<p>View your full name, email and profile image</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row items-center justify-between text-gray-600 dark:text-gray-400">
|
||||
<div className="flex flex-row items-center gap-4">
|
||||
<div className="w-3 h-3 rounded-full bg-blue-500"></div>
|
||||
<p>View your permission from "MDN" group</p>
|
||||
<p>
|
||||
{scope === "openid" &&
|
||||
"Access your account id and use it"}
|
||||
{scope === "email" && "View your email address"}
|
||||
{scope === "profile" && "View your profile image"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mb-10">
|
||||
<p className="font-medium mb-4 dark:text-gray-200">
|
||||
Are you sure you want to trust MDN Lab Services?
|
||||
Are you sure you want to trust {apiService?.name ?? "service"}
|
||||
?
|
||||
</p>
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500">
|
||||
Please do not share any sensitive, personal, or unnecessary
|
||||
|
@ -62,8 +62,9 @@ export default function LoginPage() {
|
||||
} catch (err: any) {
|
||||
console.log(err);
|
||||
setError(
|
||||
"Failed to create account. " +
|
||||
(err.message ?? "Unexpected error happened"),
|
||||
err.response?.data?.error ??
|
||||
err.message ??
|
||||
"Unexpected error happened",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user