Compare commits
6 Commits
591a6181cf
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd858b0d8c | ||
|
|
7a2b80381d | ||
|
|
72e7626122 | ||
|
|
1367829f80 | ||
|
|
167be9b0b3 | ||
|
|
2608ce6e60 |
837
.claude/CLAUDE.md
Normal file
@@ -0,0 +1,837 @@
|
||||
## Go style guide
|
||||
|
||||
MUST write valid Go v1.22+ code, best practices.
|
||||
|
||||
## Generated folders — do not edit
|
||||
|
||||
`encore.gen/` + `.encore/` CLI-regenerated; never edit.
|
||||
|
||||
`encore.app` at repo root = app manifest — CUE-formatted **text** file, not binary, despite `.app` extension. Read like any source file.
|
||||
|
||||
## Encore local MCP server
|
||||
|
||||
Local Encore MCP wired via `.mcp.json` (registered **`encore-local`**, tool prefix `mcp__encore-local__`). Prefer for live app introspection over `Read`/grep; use docs tools when unsure of Encore API surface.
|
||||
|
||||
### Tools
|
||||
|
||||
20 tools, all prefixed `mcp__encore-local__`. MCP client gets full catalog (names, descriptions, input schemas) via `tools/list` on session init — no need enumerate here.
|
||||
|
||||
### Important tools (runtime-only — no filesystem equivalent)
|
||||
|
||||
Do what `Glob`/`Grep`/`Read` can't. For static config (services, endpoints, topics, schemas), see "When NOT to use it" below.
|
||||
|
||||
- **`call_endpoint`** — args: `service`, `endpoint`, `method`, `path`, `payload` (JSON string with body/query/headers/path-params), optional `auth_token` / `auth_payload` / `correlation_id`. Optional `retry_until: { predicate, timeout_ms?, interval_ms?, fail_on_timeout? }` polls **server-side** until predicate matches — predicate = `status: 200` OR `body_path: { path: ".events.0.orderID", equals: 7 }` OR `body_jq: ".events | length > 0"` (minimal subset: `<path> | length <op> N` or `<path>` truthy). Use instead of agent-side polling loops for eventually-consistent reads. **auto-starts app** if not running. Use instead of `curl` to test endpoints.
|
||||
- **`wait_for_subscription_message`** — args: `topic`, optional `subscription`, `timeout_ms` (default 10000), `since` (ISO/RFC3339), `match` (top-level key/value JSON filter, e.g. `{"CustomerID":"cust_42"}`). Blocks until the next message on the topic is fully processed by a subscription handler (returns or errors), then returns `{outcome, payload, duration_ms, handler_error, trace_id}`. Use to bridge async Pub/Sub work into a synchronous verify step — beats polling read-side endpoints + introspecting via `get_pubsub`/`get_traces`.
|
||||
- **`query_database`** — args: `queries` (array of `{database, query}`). Runs SQL against named DBs, multiple queries one call. Beats `encore db conn-uri` + `psql` round-trips.
|
||||
- **`get_traces`** — args: optional `service`, `endpoint`, `error` (`"true"`/`"false"`), `start_time` / `end_time` (ISO), `limit`. Returns recent request traces (timing, status, ids).
|
||||
- **`get_trace_spans`** — args: `trace_ids` (array IDs from `get_traces`). Returns full per-span details for deep debugging.
|
||||
- **`get_objects`** — args: `buckets` (array of bucket names). Lists objects + metadata in storage buckets.
|
||||
- **`search_docs`** — args: `query`, optional `hits_per_page`, `page`, `facet_filters`. Algolia-backed Encore docs search.
|
||||
- **`get_docs`** — args: `paths` (array of doc paths, e.g. `/docs/go/primitives/databases`). Fetches full doc pages found via `search_docs`.
|
||||
|
||||
### When to reach for it
|
||||
|
||||
| Situation | Tool |
|
||||
|---|---|
|
||||
| "What services / endpoints / databases exist?" | `get_metadata` once |
|
||||
| "Test my new POST /orders endpoint" | `call_endpoint` (auto-starts app) |
|
||||
| "Did the Pub/Sub handler run after I published?" | `wait_for_subscription_message` |
|
||||
| "Endpoint output is eventually consistent (e.g. read-after-publish)" | `call_endpoint` with `retry_until` |
|
||||
| "Why did last request fail?" | `get_traces` then `get_trace_spans` |
|
||||
| "How does Encore API surface work?" | `search_docs` then `get_docs` |
|
||||
|
||||
- **MCP only for runtime data** (`call_endpoint`, `query_database`, `get_traces`, `get_objects`, `search_docs`/`get_docs`). For **static structure** declared in source (services, endpoints, topics, subscriptions, schemas, secrets, middleware, cron jobs), `Glob`+`Grep`+`Read` faster + more reliable than `get_*` tool.
|
||||
|
||||
### Cloud MCP (deployed environments)
|
||||
|
||||
If deployed Encore Cloud, also register `encore-cloud` via `claude mcp add --transport http encore-cloud https://api.encore.cloud/mcp` for prod traces / deploy state — `encore-local` only sees local app.
|
||||
|
||||
## Encore check (verify app builds + endpoints work)
|
||||
|
||||
`encore check` compiles, boots, health-checks app, optionally runs `curl` once healthy. Use instead of manual `encore run + healthz poll + curl`.
|
||||
|
||||
```bash
|
||||
encore check # compile + boot only
|
||||
encore check 'curl /ping' # GET a relative path
|
||||
encore check 'curl /orders -X POST -d "{\"customer_id\":\"c1\",\"amount_cents\":1999}"' # POST with JSON body (flags after path)
|
||||
encore check 'curl /a; curl /b' # chain in one quoted string
|
||||
encore check 'curl /a' 'curl /b' # or as separate quoted args
|
||||
encore check < commands.txt # one curl per line, piped from file
|
||||
```
|
||||
|
||||
Rules embedded curl DSL:
|
||||
- **Paths relative** (start with `/`). Use `curl /orders/1`, **not** `curl http://localhost:4000/orders/1` — host/port supplied by `encore check`. Absolute URL fails with `curl path "http..." must be relative`.
|
||||
- **Path first, flags after.** `curl /orders -X POST -d '...'` works; `curl -X POST /orders ...` fails — parser reads first token after `curl` as path, rejects flags there (`curl path "-X" must be relative`).
|
||||
- One curl per quoted command. Chain `;` inside one quoted string, pass multiple quoted args, or pipe file one curl per line.
|
||||
|
||||
## Pub/sub verification
|
||||
|
||||
- Delivery async + at-least-once + not order-preserving.
|
||||
- For "most-recent first" by publish order, DO NOT sort by `created_at = NOW()` or subscription-side row id — both subscription-insert order, at-least-once can flip. Sort by column monotonic with publish (entity id from event payload, or publish-time sequence in event).
|
||||
- Handler-only checks, skip infra: `et.Topic(T).PublishedMessages()`.
|
||||
|
||||
## Encore Go domain knowledge
|
||||
|
||||
### Application structure
|
||||
|
||||
Encore uses monorepo design — one app = entire backend. Enables distributed tracing + Encore Flow via unified application model. Supports monolith + microservices with monolith-style DX.
|
||||
|
||||
Directory structure:
|
||||
/app-name
|
||||
encore.app
|
||||
service1/
|
||||
migrations/
|
||||
1_create_table.up.sql
|
||||
service1.go
|
||||
service1_test.go
|
||||
service2/
|
||||
service2.go
|
||||
|
||||
Sub-packages internal to services, cannot define APIs, used for helpers + code organization.
|
||||
|
||||
Large apps — group related services into system directories (logical groupings, no special runtime behavior):
|
||||
/app-name
|
||||
encore.app
|
||||
system1/
|
||||
service1/
|
||||
service2/
|
||||
system2/
|
||||
service3/
|
||||
|
||||
### API definition
|
||||
|
||||
Create type-safe APIs from regular Go functions via //encore:api annotation.
|
||||
|
||||
Access controls:
|
||||
- public: Accessible to anyone on internet
|
||||
- private: Only accessible within app + via cron jobs
|
||||
- auth: Public but requires valid auth
|
||||
|
||||
Function signatures:
|
||||
func Foo(ctx context.Context, p *Params) (*Response, error) // full
|
||||
func Foo(ctx context.Context) (*Response, error) // response only
|
||||
func Foo(ctx context.Context, p *Params) error // request only
|
||||
func Foo(ctx context.Context) error // minimal
|
||||
|
||||
Request/response data locations:
|
||||
- header: Use `header` tag for HTTP headers
|
||||
- query: Default GET/HEAD/DELETE, uses snake_case, supports basic types/slices
|
||||
- body: Default other methods, uses `json` tag, supports complex types
|
||||
|
||||
Path parameters: Use :name for variables, *name for wildcards. Place at end of path.
|
||||
|
||||
Sensitive data:
|
||||
- Field level: `encore:"sensitive"` tag, auto-redacted in tracing
|
||||
- Endpoint level: Add `sensitive` to //encore:api annotation
|
||||
|
||||
Type support by location:
|
||||
- headers/path: bool, numeric, string, time.Time, UUID, json.RawMessage
|
||||
- query: All above plus lists
|
||||
- body: All types including structs, maps, pointers
|
||||
|
||||
### Services
|
||||
|
||||
Service defined by creating at least one API within Go package. Package name = service name.
|
||||
|
||||
//encore:service annotation enables custom init + graceful shutdown:
|
||||
|
||||
type Service struct {
|
||||
// Dependencies here
|
||||
}
|
||||
|
||||
func initService() (*Service, error) {
|
||||
// Initialization code
|
||||
}
|
||||
|
||||
//encore:api public
|
||||
func (s *Service) MyAPI(ctx context.Context) error {
|
||||
// API implementation
|
||||
}
|
||||
|
||||
Graceful shutdown via Shutdown method:
|
||||
func (s *Service) Shutdown(force context.Context)
|
||||
- Graceful phase: Several seconds for completion
|
||||
- Forced phase: When force context canceled, terminate immediately
|
||||
|
||||
### Raw endpoints
|
||||
|
||||
Lower-level HTTP access (webhooks, WebSockets):
|
||||
|
||||
//encore:api public raw
|
||||
func Webhook(w http.ResponseWriter, req *http.Request) {
|
||||
// Process raw HTTP request
|
||||
}
|
||||
|
||||
//encore:api public raw method=POST path=/webhook/:id
|
||||
func Webhook(w http.ResponseWriter, req *http.Request) {
|
||||
id := encore.CurrentRequest().PathParams.Get("id")
|
||||
}
|
||||
|
||||
### SQL databases
|
||||
|
||||
Encore treats SQL databases as logical resources with native PostgreSQL support.
|
||||
|
||||
Create database:
|
||||
var tododb = sqldb.NewDatabase("todo", sqldb.DatabaseConfig{
|
||||
Migrations: "./migrations",
|
||||
})
|
||||
|
||||
Migration naming: number_description.up.sql (e.g., 1_create_table.up.sql)
|
||||
Migrations folder structure:
|
||||
service/
|
||||
migrations/
|
||||
1_create_table.up.sql
|
||||
2_add_field.up.sql
|
||||
service.go
|
||||
|
||||
Data operations:
|
||||
// Insert
|
||||
_, err := tododb.Exec(ctx, `
|
||||
INSERT INTO todo_item (id, title, done)
|
||||
VALUES ($1, $2, $3)
|
||||
`, id, title, done)
|
||||
|
||||
// Query
|
||||
err := tododb.QueryRow(ctx, `
|
||||
SELECT id, title, done FROM todo_item LIMIT 1
|
||||
`).Scan(&item.ID, &item.Title, &item.Done)
|
||||
// Use errors.Is(err, sqldb.ErrNoRows) for no results
|
||||
|
||||
Always Scan into typed struct fields, never `interface{}` slots — compiler enforces column-to-field shape + catches drift between SELECT list + destination struct.
|
||||
|
||||
CLI commands:
|
||||
- encore db shell database-name [--env=name] - Open psql shell
|
||||
- encore db conn-uri database-name [--env=name] - Output connection string
|
||||
- encore db proxy [--env=name] - Setup local connection proxy
|
||||
|
||||
### External databases
|
||||
|
||||
Existing databases — create dedicated package with lazy connection pool:
|
||||
|
||||
package externaldb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/jackc/pgx/v4/pgxpool"
|
||||
"go4.org/syncutil"
|
||||
)
|
||||
|
||||
func Get(ctx context.Context) (*pgxpool.Pool, error) {
|
||||
err := once.Do(func() error {
|
||||
var err error
|
||||
pool, err = setup(ctx)
|
||||
return err
|
||||
})
|
||||
return pool, err
|
||||
}
|
||||
|
||||
var (
|
||||
once syncutil.Once
|
||||
pool *pgxpool.Pool
|
||||
)
|
||||
|
||||
var secrets struct {
|
||||
ExternalDBPassword string
|
||||
}
|
||||
|
||||
func setup(ctx context.Context) (*pgxpool.Pool, error) {
|
||||
connString := fmt.Sprintf("postgresql://%s:%s@hostname:port/dbname?sslmode=require",
|
||||
"user", secrets.ExternalDBPassword)
|
||||
return pgxpool.Connect(ctx, connString)
|
||||
}
|
||||
|
||||
Works with Cassandra, DynamoDB, BigTable, MongoDB, Neo4j, other services.
|
||||
|
||||
### Shared databases
|
||||
|
||||
Default: per-service databases for isolation. Share via sqldb.Named:
|
||||
|
||||
// In report service, access todo service's database:
|
||||
var todoDB = sqldb.Named("todo")
|
||||
|
||||
//encore:api method=GET path=/report/todo
|
||||
func CountCompletedTodos(ctx context.Context) (*ReportResponse, error) {
|
||||
var report ReportResponse
|
||||
err := todoDB.QueryRow(ctx,`
|
||||
SELECT COUNT(*) FROM todo_item WHERE completed = TRUE
|
||||
`).Scan(&report.Total)
|
||||
return &report, err
|
||||
}
|
||||
|
||||
### Cron jobs
|
||||
|
||||
Declarative periodic tasks. Does not run locally or in Preview Environments.
|
||||
|
||||
import "encore.dev/cron"
|
||||
|
||||
var _ = cron.NewJob("welcome-email", cron.JobConfig{
|
||||
Title: "Send welcome emails",
|
||||
Every: 2 * cron.Hour,
|
||||
Endpoint: SendWelcomeEmail,
|
||||
})
|
||||
|
||||
//encore:api private
|
||||
func SendWelcomeEmail(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
Scheduling options:
|
||||
- Every: Must divide 24 hours evenly (e.g., 10 * cron.Minute, 6 * cron.Hour)
|
||||
- Schedule: Cron expressions (e.g., "0 4 15 * *" for 4am UTC on 15th)
|
||||
|
||||
Requirements: Endpoints must be idempotent, no request parameters, signature func(context.Context) error or func(context.Context) (*T, error)
|
||||
|
||||
### Caching
|
||||
|
||||
Redis-based distributed caching.
|
||||
|
||||
import "encore.dev/storage/cache"
|
||||
|
||||
var MyCacheCluster = cache.NewCluster("my-cache-cluster", cache.ClusterConfig{
|
||||
EvictionPolicy: cache.AllKeysLRU,
|
||||
})
|
||||
|
||||
// Keyspace with type safety
|
||||
var RequestsPerUser = cache.NewIntKeyspace[auth.UID](cluster, cache.KeyspaceConfig{
|
||||
KeyPattern: "requests/:key",
|
||||
DefaultExpiry: cache.ExpireIn(10 * time.Second),
|
||||
})
|
||||
|
||||
// Structured keys
|
||||
type MyKey struct {
|
||||
UserID auth.UID
|
||||
ResourcePath string
|
||||
}
|
||||
var ResourceRequestsPerUser = cache.NewIntKeyspace[MyKey](cluster, cache.KeyspaceConfig{
|
||||
KeyPattern: "requests/:UserID/:ResourcePath",
|
||||
DefaultExpiry: cache.ExpireIn(10 * time.Second),
|
||||
})
|
||||
|
||||
Supports strings, integers, floats, structs, sets, ordered lists.
|
||||
|
||||
### Object storage
|
||||
|
||||
Cloud-agnostic API compatible with S3, GCS, S3-compatible services.
|
||||
|
||||
var ProfilePictures = objects.NewBucket("profile-pictures", objects.BucketConfig{
|
||||
Versioned: false,
|
||||
})
|
||||
|
||||
// Public bucket with CDN
|
||||
var PublicAssets = objects.NewBucket("public-assets", objects.BucketConfig{
|
||||
Public: true,
|
||||
})
|
||||
|
||||
Operations: Upload, Download, List, Remove, Attrs, Exists
|
||||
|
||||
Bucket references for permissions:
|
||||
type myPerms interface {
|
||||
objects.Downloader
|
||||
objects.Uploader
|
||||
}
|
||||
ref := objects.BucketRef[myPerms](bucket)
|
||||
|
||||
### Pub/Sub
|
||||
|
||||
Async event broadcasting with automatic infra provisioning.
|
||||
|
||||
type SignupEvent struct{ UserID int }
|
||||
|
||||
var Signups = pubsub.NewTopic[*SignupEvent]("signups", pubsub.TopicConfig{
|
||||
DeliveryGuarantee: pubsub.AtLeastOnce,
|
||||
})
|
||||
|
||||
// Publishing
|
||||
messageID, err := Signups.Publish(ctx, &SignupEvent{UserID: id})
|
||||
|
||||
// Topic reference
|
||||
signupRef := pubsub.TopicRef[pubsub.Publisher[*SignupEvent]](Signups)
|
||||
|
||||
// Subscribing
|
||||
var _ = pubsub.NewSubscription(
|
||||
user.Signups, "send-welcome-email",
|
||||
pubsub.SubscriptionConfig[*SignupEvent]{
|
||||
Handler: SendWelcomeEmail,
|
||||
},
|
||||
)
|
||||
|
||||
// Method handler with dependency injection
|
||||
var _ = pubsub.NewSubscription(
|
||||
user.Signups, "send-welcome-email",
|
||||
pubsub.SubscriptionConfig[*SignupEvent]{
|
||||
Handler: pubsub.MethodHandler((*Service).SendWelcomeEmail),
|
||||
},
|
||||
)
|
||||
|
||||
Delivery guarantees:
|
||||
- AtLeastOnce: Handlers must be idempotent
|
||||
- ExactlyOnce: Stronger guarantees (AWS: 300 msg/sec, GCP: 3000+ msg/sec)
|
||||
|
||||
Ordering: Use OrderingAttribute matching pubsub-attr tag
|
||||
|
||||
Testing:
|
||||
msgs := et.Topic(Signups).PublishedMessages()
|
||||
assert.Len(t, msgs, 1)
|
||||
|
||||
### Secrets
|
||||
|
||||
Built-in secrets manager for API keys, passwords, private keys.
|
||||
|
||||
var secrets struct {
|
||||
SSHPrivateKey string
|
||||
GitHubAPIToken string
|
||||
}
|
||||
|
||||
func callGitHub(ctx context.Context) {
|
||||
req.Header.Add("Authorization", "token " + secrets.GitHubAPIToken)
|
||||
}
|
||||
|
||||
CLI management:
|
||||
- encore secret set --type production secret-name
|
||||
- encore secret set --type development secret-name
|
||||
- encore secret set --env env-name secret-name (env-specific override)
|
||||
|
||||
Types: production (prod), development (dev), preview (pr), local
|
||||
|
||||
Local override via .secrets.local.cue:
|
||||
GitHubAPIToken: "my-local-override-token"
|
||||
|
||||
### API calls
|
||||
|
||||
Call APIs like regular functions with automatic type checking:
|
||||
|
||||
import "encore.app/hello"
|
||||
|
||||
//encore:api public
|
||||
func MyOtherAPI(ctx context.Context) error {
|
||||
resp, err := hello.Ping(ctx, &hello.PingParams{Name: "World"})
|
||||
if err == nil {
|
||||
log.Println(resp.Message) // "Hello, World!"
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
### Errors
|
||||
|
||||
Structured errors via encore.dev/beta/errs package.
|
||||
|
||||
return &errs.Error{
|
||||
Code: errs.NotFound,
|
||||
Message: "sprocket not found",
|
||||
}
|
||||
// Returns HTTP 404 {"code": "not_found", "message": "sprocket not found"}
|
||||
|
||||
Wrapping:
|
||||
errs.Wrap(err, msg, metaPairs...)
|
||||
errs.WrapCode(err, code, msg, metaPairs...)
|
||||
|
||||
Builder pattern:
|
||||
eb := errs.B().Meta("board_id", params.ID)
|
||||
return eb.Code(errs.NotFound).Msg("board not found").Err()
|
||||
|
||||
Error codes: OK(200), Canceled(499), Unknown(500), InvalidArgument(400), DeadlineExceeded(504), NotFound(404), AlreadyExists(409), PermissionDenied(403), ResourceExhausted(429), FailedPrecondition(400), Aborted(409), OutOfRange(400), Unimplemented(501), Internal(500), Unavailable(503), DataLoss(500), Unauthenticated(401)
|
||||
|
||||
Inspection: errs.Code(err), errs.Meta(err), errs.Details(err)
|
||||
|
||||
### Authentication
|
||||
|
||||
Flexible auth with different access levels.
|
||||
|
||||
import "encore.dev/beta/auth"
|
||||
|
||||
// Basic
|
||||
//encore:authhandler
|
||||
func AuthHandler(ctx context.Context, token string) (auth.UID, error) {
|
||||
// Validate token and return user ID
|
||||
}
|
||||
|
||||
// With user data
|
||||
type Data struct {
|
||||
Username string
|
||||
}
|
||||
|
||||
//encore:authhandler
|
||||
func AuthHandler(ctx context.Context, token string) (auth.UID, *Data, error) {
|
||||
// Return user ID and custom data
|
||||
}
|
||||
|
||||
// Structured auth params
|
||||
type MyAuthParams struct {
|
||||
SessionCookie *http.Cookie `cookie:"session"`
|
||||
ClientID string `query:"client_id"`
|
||||
Authorization string `header:"Authorization"`
|
||||
}
|
||||
|
||||
//encore:authhandler
|
||||
func AuthHandler(ctx context.Context, p *MyAuthParams) (auth.UID, error) {
|
||||
// Process structured auth params
|
||||
}
|
||||
|
||||
Usage: auth.Data(), auth.UserID()
|
||||
Override for testing: auth.WithContext(ctx, auth.UID("my-user-id"), &MyAuthData{})
|
||||
|
||||
Error handling:
|
||||
return "", &errs.Error{
|
||||
Code: errs.Unauthenticated,
|
||||
Message: "invalid token",
|
||||
}
|
||||
|
||||
### Configuration
|
||||
|
||||
Env-specific config via CUE files.
|
||||
|
||||
package mysvc
|
||||
|
||||
import "encore.dev/config"
|
||||
|
||||
type SomeConfigType struct {
|
||||
ReadOnly config.Bool
|
||||
Example config.String
|
||||
}
|
||||
|
||||
var cfg *SomeConfigType = config.Load[*SomeConfigType]()
|
||||
|
||||
CUE tags for constraints:
|
||||
type FooBar {
|
||||
A int `cue:">100"`
|
||||
B int `cue:"A-50"`
|
||||
C int `cue:"A+B"`
|
||||
}
|
||||
|
||||
Config types: config.String, config.Bool, config.Int, config.Float64, config.Time, config.UUID, config.Value[T], config.Values[T]
|
||||
|
||||
Meta values:
|
||||
- APIBaseURL, Environment.Name, Environment.Type (production/development/ephemeral/test), Environment.Cloud (aws/gcp/encore/local)
|
||||
|
||||
Testing: et.SetCfg(cfg.SendEmails, true)
|
||||
|
||||
CUE patterns:
|
||||
- Defaults: value: type | *default_value
|
||||
- Switch: array with conditionals, take [0]
|
||||
|
||||
### CORS
|
||||
|
||||
Configure in encore.app file:
|
||||
- debug: Enable CORS debug logging
|
||||
- allow_headers: Additional accepted headers ("*" allows all)
|
||||
- expose_headers: Additional exposed headers
|
||||
- allow_origins_without_credentials: Defaults to ["*"]
|
||||
- allow_origins_with_credentials: For authenticated requests, supports wildcards like "https://*.example.com"
|
||||
|
||||
### Metadata
|
||||
|
||||
Access app + request info via encore.dev package.
|
||||
|
||||
// Application metadata
|
||||
meta := encore.Meta()
|
||||
// meta.AppID, meta.APIBaseURL, meta.Environment, meta.Build, meta.Deploy
|
||||
|
||||
// Request metadata
|
||||
req := encore.CurrentRequest()
|
||||
// req.Service, req.Endpoint, req.Path, req.StartTime
|
||||
|
||||
// Cloud-specific behavior
|
||||
switch encore.Meta().Environment.Cloud {
|
||||
case encore.CloudAWS:
|
||||
return writeIntoRedshift(ctx, action, user)
|
||||
case encore.CloudGCP:
|
||||
return writeIntoBigQuery(ctx, action, user)
|
||||
}
|
||||
|
||||
### Middleware
|
||||
|
||||
Reusable code running before/after API requests.
|
||||
|
||||
//encore:middleware global target=all
|
||||
func ValidationMiddleware(req middleware.Request, next middleware.Next) middleware.Response {
|
||||
payload := req.Data().Payload
|
||||
if validator, ok := payload.(interface { Validate() error }); ok {
|
||||
if err := validator.Validate(); err != nil {
|
||||
err = errs.WrapCode(err, errs.InvalidArgument, "validation failed")
|
||||
return middleware.Response{Err: err}
|
||||
}
|
||||
}
|
||||
return next(req)
|
||||
}
|
||||
|
||||
// With dependency injection
|
||||
//encore:middleware target=all
|
||||
func (s *Service) MyMiddleware(req middleware.Request, next middleware.Next) middleware.Response {
|
||||
// Implementation
|
||||
}
|
||||
|
||||
// Tag-based targeting
|
||||
//encore:middleware target=tag:cache
|
||||
func CachingMiddleware(req middleware.Request, next middleware.Next) middleware.Response {
|
||||
// ...
|
||||
}
|
||||
|
||||
//encore:api public method=GET path=/user/:id tag:cache
|
||||
func GetUser(ctx context.Context, id string) (*User, error) {
|
||||
// Implementation
|
||||
}
|
||||
|
||||
Ordering: Global before service-specific, lexicographic by filename.
|
||||
|
||||
### Mocking
|
||||
|
||||
Built-in mocking for isolated testing.
|
||||
|
||||
// Mock endpoint for single test
|
||||
func Test_Something(t *testing.T) {
|
||||
t.Parallel()
|
||||
et.MockEndpoint(products.GetPrice, func(ctx context.Context, p *products.PriceParams) (*products.PriceResponse, error) {
|
||||
return &products.PriceResponse{Price: 100}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// Mock endpoint for all tests in package
|
||||
func TestMain(m *testing.M) {
|
||||
et.MockEndpoint(products.GetPrice, func(ctx context.Context, p *products.PriceParams) (*products.PriceResponse, error) {
|
||||
return &products.PriceResponse{Price: 100}, nil
|
||||
})
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
// Mock entire service
|
||||
et.MockService("products", &products.Service{
|
||||
SomeField: "a testing value",
|
||||
})
|
||||
|
||||
// Type-safe service mocking
|
||||
et.MockService[products.Interface]("products", &myMockObject{})
|
||||
|
||||
### Testing
|
||||
|
||||
Run tests: encore test ./...
|
||||
Supports all standard go test flags. Built-in tracing at localhost:9400.
|
||||
|
||||
Database testing:
|
||||
- Automatic setup in separate cluster, optimized for speed
|
||||
- Temporary databases: et.NewTestDatabase() creates isolated, fully migrated DB
|
||||
|
||||
Service structs: Lazy init, instance sharing between tests
|
||||
- Isolate: et.EnableServiceInstanceIsolation()
|
||||
|
||||
### Validation
|
||||
|
||||
Automatic request validation via Validate() method.
|
||||
|
||||
type MyRequest struct {
|
||||
Email string
|
||||
}
|
||||
|
||||
func (r *MyRequest) Validate() error {
|
||||
if !isValidEmail(r.Email) {
|
||||
return &errs.Error{Code: errs.InvalidArgument, Message: "invalid email"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Validation runs after deserialization, before handler. Non-errs.Error errors become InvalidArgument (HTTP 400).
|
||||
|
||||
### CGO
|
||||
|
||||
Enable in encore.app:
|
||||
{
|
||||
"id": "my-app-id",
|
||||
"build": {
|
||||
"cgo_enabled": true
|
||||
}
|
||||
}
|
||||
Uses Ubuntu builder with gcc. Libraries must support static linking.
|
||||
|
||||
### Clerk auth
|
||||
|
||||
Implement Clerk auth:
|
||||
|
||||
package auth
|
||||
|
||||
import "github.com/clerkinc/clerk-sdk-go/clerk"
|
||||
|
||||
type Service struct {
|
||||
client clerk.Client
|
||||
}
|
||||
|
||||
func initService() (*Service, error) {
|
||||
client, err := clerk.NewClient(secrets.ClientSecretKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Service{client: client}, nil
|
||||
}
|
||||
|
||||
type UserData struct {
|
||||
ID string
|
||||
Username *string
|
||||
FirstName *string
|
||||
LastName *string
|
||||
ProfileImageURL string
|
||||
PrimaryEmailAddressID *string
|
||||
EmailAddresses []clerk.EmailAddress
|
||||
}
|
||||
|
||||
//encore:authhandler
|
||||
func (s *Service) AuthHandler(ctx context.Context, token string) (auth.UID, *UserData, error) {
|
||||
// Token verification and user data retrieval
|
||||
}
|
||||
|
||||
Set secrets:
|
||||
- encore secret set --prod ClientSecretKey
|
||||
- encore secret set --dev ClientSecretKey
|
||||
|
||||
### Dependency injection
|
||||
|
||||
Add dependencies as struct fields for easy testing:
|
||||
|
||||
package email
|
||||
|
||||
//encore:service
|
||||
type Service struct {
|
||||
sendgridClient *sendgrid.Client
|
||||
}
|
||||
|
||||
func initService() (*Service, error) {
|
||||
client, err := sendgrid.NewClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Service{sendgridClient: client}, nil
|
||||
}
|
||||
|
||||
//encore:api private
|
||||
func (s *Service) Send(ctx context.Context, p *SendParams) error {
|
||||
// Use s.sendgridClient
|
||||
}
|
||||
|
||||
// For testing, use interface
|
||||
type sendgridClient interface {
|
||||
SendEmail(...)
|
||||
}
|
||||
|
||||
func TestFoo(t *testing.T) {
|
||||
svc := &Service{sendgridClient: &myMockClient{}}
|
||||
// Test
|
||||
}
|
||||
|
||||
### Pub/Sub outbox
|
||||
|
||||
Transactional outbox pattern for database + Pub/Sub consistency.
|
||||
|
||||
var SignupsTopic = pubsub.NewTopic[*SignupEvent](/* ... */)
|
||||
ref := pubsub.TopicRef[pubsub.Publisher[*SignupEvent]](SignupsTopic)
|
||||
ref = outbox.Bind(ref, outbox.TxPersister(tx))
|
||||
|
||||
Required schema:
|
||||
CREATE TABLE outbox (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
topic TEXT NOT NULL,
|
||||
data JSONB NOT NULL,
|
||||
inserted_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE INDEX outbox_topic_idx ON outbox (topic, id);
|
||||
|
||||
Relay setup:
|
||||
type Service struct {
|
||||
signupsRef pubsub.Publisher[*SignupEvent]
|
||||
}
|
||||
|
||||
func initService() (*Service, error) {
|
||||
relay := outbox.NewRelay(outbox.SQLDBStore(db))
|
||||
signupsRef := pubsub.TopicRef[pubsub.Publisher[*SignupEvent]](SignupsTopic)
|
||||
outbox.RegisterTopic(relay, signupsRef)
|
||||
go relay.PollForMessage(context.Background(), -1)
|
||||
return &Service{signupsRef: signupsRef}, nil
|
||||
}
|
||||
|
||||
Supports: encore.dev/storage/sqldb, database/sql, github.com/jackc/pgx/v5
|
||||
|
||||
### Encore Toolbar (frontend dev panel)
|
||||
|
||||
Dev-only browser panel: intercepts frontend HTTP calls, captures Encore trace IDs, jumps to traces in the dashboard. Install: add `<script src="https://encore.dev/encore-toolbar.js"></script>` early in `<head>` (no `async`/`defer`). Reach for it when a frontend talks to an Encore Go backend.
|
||||
|
||||
### Example apps
|
||||
|
||||
- Hello World: https://github.com/encoredev/examples/tree/main/hello-world
|
||||
- URL Shortener: https://github.com/encoredev/examples/tree/main/url-shortener
|
||||
- Uptime Monitor: https://github.com/encoredev/examples/tree/main/uptime
|
||||
|
||||
## Encore CLI reference
|
||||
|
||||
Execution:
|
||||
- encore help [command] - Lists available commands (or shows usage for specific command). Reach for it when unsure which subcommand or flag to use.
|
||||
- encore run [--debug] [--watch=true] [-p port] [flags] - Runs app. Before starting yourself, check if already running by polling `http://localhost:<port>/__encore/healthz` (default port `4000`) — `200` = healthy, hit endpoints directly without re-running.
|
||||
- encore check ['curl ...'; 'curl ...'] | encore check < commands.txt - Compile + boot app, verify every service healthy, optionally run curl commands. See dedicated section above.
|
||||
- encore test ./... [go test flags] - Wraps `go test ./...` with Encore's infra provisioning — boots isolated test databases, Pub/Sub, etc. before handing off to `go test`. Accepts all `go test` flags, plus `--prepare`, `--codegen-debug`, `--trace`.
|
||||
- encore exec path/to/script [args...] - Run an executable script against the local Encore app
|
||||
|
||||
App management:
|
||||
- encore app clone [app-id] [directory] - Clone app
|
||||
- encore app create [name] [--example=name] [-l go] - Create new app
|
||||
- encore app init [name] - Create from existing repo
|
||||
- encore app link [app-id] [-f] - Link app with server
|
||||
|
||||
Authentication:
|
||||
- encore auth login/logout/signup/whoami
|
||||
|
||||
Daemon:
|
||||
- encore daemon - Restart daemon
|
||||
- encore daemon env - Output environment info
|
||||
|
||||
Database:
|
||||
- encore db shell database-name [--env=name] - psql shell (--write, --admin, --superuser)
|
||||
- encore db conn-uri database-name [--env=name] - Connection string
|
||||
- encore db proxy [--env=name] - Local proxy
|
||||
- encore db reset <db-names...|--all> - Reset databases
|
||||
|
||||
Code generation:
|
||||
- encore gen client [app-id] [--env=name] [--lang=lang] - Generate API client
|
||||
Languages: go, typescript, javascript, openapi
|
||||
|
||||
Logging:
|
||||
- encore logs [--env=prod] [--json] [-q] - Stream logs
|
||||
|
||||
Kubernetes:
|
||||
- encore k8s configure --env=ENV_NAME - Update kubectl config
|
||||
|
||||
Secrets:
|
||||
- encore secret set --type TYPE <secret-name> (types: production, development, preview, local)
|
||||
- encore secret set --env env-name <secret-name>
|
||||
- encore secret list [keys...]
|
||||
- encore secret delete <id>
|
||||
|
||||
Namespaces (infrastructure environments, alias `encore ns`):
|
||||
- encore namespace list [--output=columns|json] - List infra namespaces
|
||||
- encore namespace create NAME - Create a namespace
|
||||
- encore namespace switch [--create] NAME - Switch active namespace
|
||||
- encore namespace delete NAME - Delete a namespace
|
||||
|
||||
MCP (programmatic access to the local app — same surface as `.mcp.json`):
|
||||
- encore mcp run - stdio-based MCP session
|
||||
- encore mcp start - SSE-based MCP session; prints the SSE URL
|
||||
|
||||
Config:
|
||||
- encore config <key> [<value>] [--app|--global|--all] - Get/set CLI configuration
|
||||
|
||||
Telemetry:
|
||||
- encore telemetry - Status
|
||||
- encore telemetry enable / disable - Toggle telemetry reporting
|
||||
|
||||
LLM rules:
|
||||
- encore llm-rules init - Generate LLM rule files for this project
|
||||
|
||||
Random data (utility):
|
||||
- encore rand uuid [-1|-4|-6|-7] - Generate UUID (default v4)
|
||||
- encore rand bytes N [-f format] - Generate N random bytes
|
||||
- encore rand words [--sep=SEP] NUM - Generate memorable passphrase
|
||||
|
||||
Deploy (alpha):
|
||||
- encore alpha deploy --env=<name> (--commit=<sha> | --branch=<name>) - Deploy app to a cloud env
|
||||
|
||||
Version:
|
||||
- encore version - Report version
|
||||
- encore version update - Check and apply updates
|
||||
|
||||
Build:
|
||||
- encore build docker IMAGE_TAG [--base string] [--push] [--cgo] [--os] [--arch] - Build Docker image
|
||||
140
.gitignore
vendored
@@ -1,31 +1,29 @@
|
||||
# ---> Go
|
||||
# If you prefer the allow list template instead of the deny list, see community template:
|
||||
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
||||
#
|
||||
# Binaries for programs and plugins
|
||||
# Encore
|
||||
/.encore
|
||||
encore.gen.go
|
||||
encore.gen.cue
|
||||
/encore.gen
|
||||
.secrets.local.cue
|
||||
.secrets.local.cue
|
||||
|
||||
# Go
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
# Dependency directories (remove the comment below to include it)
|
||||
# vendor/
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
go.work.sum
|
||||
|
||||
# env file
|
||||
# Environment files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# ---> Node
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
@@ -35,7 +33,7 @@ yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
# Diagnostic reports
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
@@ -44,122 +42,52 @@ pids
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
# Coverage and build output
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
dist
|
||||
out
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
bower_components/
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
# Tool caches
|
||||
lib-cov
|
||||
.grunt
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.parcel-cache
|
||||
.next
|
||||
.nuxt
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# vitepress build output
|
||||
.vuepress/dist
|
||||
**/.vitepress/dist
|
||||
|
||||
# vitepress cache directory
|
||||
**/.vitepress/cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Packaging
|
||||
*.tgz
|
||||
|
||||
# Yarn
|
||||
.yarn-integrity
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
|
||||
12
.mcp.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"encore-local": {
|
||||
"args": [
|
||||
"mcp",
|
||||
"run",
|
||||
"--app=q55oi"
|
||||
],
|
||||
"command": "encore"
|
||||
}
|
||||
}
|
||||
}
|
||||
14
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Connect to server",
|
||||
"type": "go",
|
||||
"request": "attach",
|
||||
"mode": "remote",
|
||||
"remotePath": "${workspaceFolder}",
|
||||
"port": 2345,
|
||||
"host": "127.0.0.1"
|
||||
}
|
||||
]
|
||||
}
|
||||
49
README.md
@@ -1,3 +1,50 @@
|
||||
# encore-test
|
||||
|
||||
test del framework encore
|
||||
Test del framework Encore con backend Go e frontend Quasar.
|
||||
|
||||
## Prerequisiti
|
||||
|
||||
- Encore: `brew install encoredev/tap/encore`
|
||||
- Go
|
||||
- Node.js e pnpm per il frontend
|
||||
|
||||
## Avvio locale
|
||||
|
||||
Backend:
|
||||
|
||||
```bash
|
||||
encore run
|
||||
```
|
||||
|
||||
Frontend:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## Test
|
||||
|
||||
Backend:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Frontend:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
## Note Encore
|
||||
|
||||
Con `encore run` attivo, il dashboard locale e' disponibile su:
|
||||
|
||||
```text
|
||||
http://localhost:9400/
|
||||
```
|
||||
|
||||
Le API locali sono esposte dall'ambiente Encore locale. Consulta il Service Catalog nel dashboard per endpoint e schema aggiornati.
|
||||
|
||||
339
admin/admin.go
Normal file
@@ -0,0 +1,339 @@
|
||||
// Service admin provides administrative endpoints for managing application data.
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
authsvc "encore.app/auth"
|
||||
profilessvc "encore.app/profiles"
|
||||
"encore.dev/beta/errs"
|
||||
"encore.dev/storage/sqldb"
|
||||
"encore.dev/types/uuid"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
var profilesDB = sqldb.Named("profiles")
|
||||
|
||||
type Profile struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display_name"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
IsArtist bool `json:"is_artist"`
|
||||
Role string `json:"role"`
|
||||
Status profilessvc.Status `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ProfileParams struct {
|
||||
DisplayName string `json:"display_name"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
}
|
||||
|
||||
type RegisterParams struct {
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display_name"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
Password string `json:"password" encore:"sensitive"`
|
||||
}
|
||||
|
||||
type ListProfilesResponse struct {
|
||||
Profiles []*Profile `json:"profiles"`
|
||||
}
|
||||
|
||||
// ListProfiles returns all user profiles, ordered by user ID.
|
||||
//
|
||||
//encore:api auth method=GET path=/admin/profiles
|
||||
func ListProfiles(ctx context.Context) (*ListProfilesResponse, error) {
|
||||
rows, err := profilesDB.Query(ctx, `
|
||||
SELECT user_id, email, display_name, avatar_url, role, status, is_artist, created_at, updated_at
|
||||
FROM user_profiles
|
||||
ORDER BY user_id
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "failed to list profiles")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
profiles := []*Profile{}
|
||||
for rows.Next() {
|
||||
var p Profile
|
||||
if err := rows.Scan(&p.UserID, &p.Email, &p.DisplayName, &p.AvatarURL, &p.Role, &p.Status, &p.IsArtist, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "failed to scan profile")
|
||||
}
|
||||
profiles = append(profiles, &p)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "failed to list profiles")
|
||||
}
|
||||
return &ListProfilesResponse{Profiles: profiles}, nil
|
||||
}
|
||||
|
||||
// GetProfile returns the profile for the given user.
|
||||
//
|
||||
//encore:api auth method=GET path=/admin/profiles/:userID
|
||||
func GetProfile(ctx context.Context, userID uuid.UUID) (*Profile, error) {
|
||||
p := Profile{UserID: userID}
|
||||
err := profilesDB.QueryRow(ctx, `
|
||||
SELECT email, display_name, avatar_url, role, status, is_artist, created_at, updated_at FROM user_profiles WHERE user_id = $1
|
||||
`, userID).Scan(&p.Email, &p.DisplayName, &p.AvatarURL, &p.Role, &p.Status, &p.IsArtist, &p.CreatedAt, &p.UpdatedAt)
|
||||
if errors.Is(err, sqldb.ErrNoRows) {
|
||||
return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "failed to fetch profile")
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// InsertProfile creates a new user profile with a server-generated UUID v4,
|
||||
// delegating credential setup to the auth service.
|
||||
//
|
||||
//encore:api auth method=POST path=/admin/profiles
|
||||
func InsertProfile(ctx context.Context, p *RegisterParams) (*Profile, error) {
|
||||
userID, err := uuid.NewV4()
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "failed to generate profile id")
|
||||
}
|
||||
profile := Profile{UserID: userID, Email: p.Email, DisplayName: p.DisplayName, AvatarURL: p.AvatarURL}
|
||||
err = profilesDB.QueryRow(ctx, `
|
||||
INSERT INTO user_profiles (user_id, email, display_name, avatar_url)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING role, status, is_artist, created_at, updated_at
|
||||
`, userID, p.Email, p.DisplayName, p.AvatarURL).Scan(&profile.Role, &profile.Status, &profile.IsArtist, &profile.CreatedAt, &profile.UpdatedAt)
|
||||
if isUniqueViolation(err) {
|
||||
return nil, &errs.Error{Code: errs.AlreadyExists, Message: "email already registered"}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "failed to create profile")
|
||||
}
|
||||
if err := authsvc.Register(ctx, &authsvc.RegisterParams{UserID: userID, Password: p.Password}); err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "failed to register credentials")
|
||||
}
|
||||
return &profile, nil
|
||||
}
|
||||
|
||||
// UpdateProfile replaces the data of any user's profile.
|
||||
//
|
||||
//encore:api auth method=PUT path=/admin/profiles/:userID
|
||||
func UpdateProfile(ctx context.Context, userID uuid.UUID, p *ProfileParams) (*Profile, error) {
|
||||
profile := Profile{UserID: userID, DisplayName: p.DisplayName, AvatarURL: p.AvatarURL}
|
||||
err := profilesDB.QueryRow(ctx, `
|
||||
UPDATE user_profiles
|
||||
SET display_name = $2, avatar_url = $3, updated_at = NOW()
|
||||
WHERE user_id = $1
|
||||
RETURNING email, role, status, is_artist, created_at, updated_at
|
||||
`, userID, p.DisplayName, p.AvatarURL).Scan(&profile.Email, &profile.Role, &profile.Status, &profile.IsArtist, &profile.CreatedAt, &profile.UpdatedAt)
|
||||
if errors.Is(err, sqldb.ErrNoRows) {
|
||||
return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "failed to update profile")
|
||||
}
|
||||
return &profile, nil
|
||||
}
|
||||
|
||||
type UpdateProfileStatusParams struct {
|
||||
Status profilessvc.Status `json:"status"`
|
||||
}
|
||||
|
||||
func (p *UpdateProfileStatusParams) Validate() error {
|
||||
if !p.Status.IsValid() {
|
||||
return &errs.Error{Code: errs.InvalidArgument, Message: "invalid status"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateProfileStatus sets the status of any user's profile.
|
||||
//
|
||||
//encore:api auth method=PUT path=/admin/profiles/:userID/status
|
||||
func UpdateProfileStatus(ctx context.Context, userID uuid.UUID, p *UpdateProfileStatusParams) (*Profile, error) {
|
||||
profile := Profile{UserID: userID, Status: p.Status}
|
||||
err := profilesDB.QueryRow(ctx, `
|
||||
UPDATE user_profiles
|
||||
SET status = $2, updated_at = NOW()
|
||||
WHERE user_id = $1
|
||||
RETURNING email, display_name, avatar_url, role, is_artist, created_at, updated_at
|
||||
`, userID, p.Status).Scan(&profile.Email, &profile.DisplayName, &profile.AvatarURL, &profile.Role, &profile.IsArtist, &profile.CreatedAt, &profile.UpdatedAt)
|
||||
if errors.Is(err, sqldb.ErrNoRows) {
|
||||
return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "failed to update profile status")
|
||||
}
|
||||
return &profile, nil
|
||||
}
|
||||
|
||||
type UpdateProfileRoleParams struct {
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
func (p *UpdateProfileRoleParams) Validate() error {
|
||||
if !authsvc.IsValidRole(p.Role) {
|
||||
return &errs.Error{Code: errs.InvalidArgument, Message: "invalid role"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateProfileRole sets the role of any user's profile.
|
||||
//
|
||||
//encore:api auth method=PUT path=/admin/profiles/:userID/role
|
||||
func UpdateProfileRole(ctx context.Context, userID uuid.UUID, p *UpdateProfileRoleParams) (*Profile, error) {
|
||||
profile := Profile{UserID: userID, Role: p.Role}
|
||||
err := profilesDB.QueryRow(ctx, `
|
||||
UPDATE user_profiles
|
||||
SET role = $2, updated_at = NOW()
|
||||
WHERE user_id = $1
|
||||
RETURNING email, display_name, avatar_url, status, is_artist, created_at, updated_at
|
||||
`, userID, p.Role).Scan(&profile.Email, &profile.DisplayName, &profile.AvatarURL, &profile.Status, &profile.IsArtist, &profile.CreatedAt, &profile.UpdatedAt)
|
||||
if errors.Is(err, sqldb.ErrNoRows) {
|
||||
return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "failed to update profile role")
|
||||
}
|
||||
return &profile, nil
|
||||
}
|
||||
|
||||
type UpdateProfilePasswordParams struct {
|
||||
Password string `json:"password" encore:"sensitive"`
|
||||
}
|
||||
|
||||
// UpdateProfilePassword sets the password of any user's profile.
|
||||
//
|
||||
//encore:api auth method=PUT path=/admin/profiles/:userID/password
|
||||
func UpdateProfilePassword(ctx context.Context, userID uuid.UUID, p *UpdateProfilePasswordParams) error {
|
||||
if err := authsvc.SetUserPassword(ctx, userID, &authsvc.SetUserPasswordParams{UserID: userID, Password: p.Password}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type UpdateProfileArtistParams struct {
|
||||
IsArtist bool `json:"is_artist"`
|
||||
}
|
||||
|
||||
// UpdateProfileArtist sets the artist flag of any user's profile.
|
||||
//
|
||||
//encore:api auth method=PUT path=/admin/profiles/:userID/artist
|
||||
func UpdateProfileArtist(ctx context.Context, userID uuid.UUID, p *UpdateProfileArtistParams) (*Profile, error) {
|
||||
profile := Profile{UserID: userID, IsArtist: p.IsArtist}
|
||||
err := profilesDB.QueryRow(ctx, `
|
||||
UPDATE user_profiles
|
||||
SET is_artist = $2, updated_at = NOW()
|
||||
WHERE user_id = $1
|
||||
RETURNING email, display_name, avatar_url, role, status, created_at, updated_at
|
||||
`, userID, p.IsArtist).Scan(&profile.Email, &profile.DisplayName, &profile.AvatarURL, &profile.Role, &profile.Status, &profile.CreatedAt, &profile.UpdatedAt)
|
||||
if errors.Is(err, sqldb.ErrNoRows) {
|
||||
return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "failed to update profile artist flag")
|
||||
}
|
||||
return &profile, nil
|
||||
}
|
||||
|
||||
// DeleteProfile removes any user's profile.
|
||||
//
|
||||
//encore:api auth method=DELETE path=/admin/profiles/:userID
|
||||
func DeleteProfile(ctx context.Context, userID uuid.UUID) error {
|
||||
res, err := profilesDB.Exec(ctx, `
|
||||
DELETE FROM user_profiles WHERE user_id = $1
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return errs.WrapCode(err, errs.Internal, "failed to delete profile")
|
||||
}
|
||||
if res.RowsAffected() == 0 {
|
||||
return &errs.Error{Code: errs.NotFound, Message: "profile not found"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type PersonalData struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Address string `json:"address"`
|
||||
Cap string `json:"cap"`
|
||||
City string `json:"city"`
|
||||
Country string `json:"country"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type PersonalDataParams struct {
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Address string `json:"address"`
|
||||
Cap string `json:"cap"`
|
||||
City string `json:"city"`
|
||||
Country string `json:"country"`
|
||||
}
|
||||
|
||||
// GetPersonalData returns the personal data for the given user.
|
||||
//
|
||||
//encore:api auth method=GET path=/admin/profiles/:userID/personal-data
|
||||
func GetPersonalData(ctx context.Context, userID uuid.UUID) (*PersonalData, error) {
|
||||
pd := PersonalData{UserID: userID}
|
||||
err := profilesDB.QueryRow(ctx, `
|
||||
SELECT first_name, last_name, address, cap, city, country, created_at, updated_at
|
||||
FROM personal_data WHERE user_id = $1
|
||||
`, userID).Scan(&pd.FirstName, &pd.LastName, &pd.Address, &pd.Cap, &pd.City, &pd.Country, &pd.CreatedAt, &pd.UpdatedAt)
|
||||
if errors.Is(err, sqldb.ErrNoRows) {
|
||||
return nil, &errs.Error{Code: errs.NotFound, Message: "personal data not found"}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "failed to fetch personal data")
|
||||
}
|
||||
return &pd, nil
|
||||
}
|
||||
|
||||
// UpsertPersonalData creates or replaces the personal data for the given user.
|
||||
//
|
||||
//encore:api auth method=PUT path=/admin/profiles/:userID/personal-data
|
||||
func UpsertPersonalData(ctx context.Context, userID uuid.UUID, p *PersonalDataParams) (*PersonalData, error) {
|
||||
pd := PersonalData{
|
||||
UserID: userID,
|
||||
FirstName: p.FirstName,
|
||||
LastName: p.LastName,
|
||||
Address: p.Address,
|
||||
Cap: p.Cap,
|
||||
City: p.City,
|
||||
Country: p.Country,
|
||||
}
|
||||
err := profilesDB.QueryRow(ctx, `
|
||||
INSERT INTO personal_data (user_id, first_name, last_name, address, cap, city, country)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (user_id) DO UPDATE
|
||||
SET first_name = EXCLUDED.first_name,
|
||||
last_name = EXCLUDED.last_name,
|
||||
address = EXCLUDED.address,
|
||||
cap = EXCLUDED.cap,
|
||||
city = EXCLUDED.city,
|
||||
country = EXCLUDED.country,
|
||||
updated_at = NOW()
|
||||
RETURNING created_at, updated_at
|
||||
`, userID, p.FirstName, p.LastName, p.Address, p.Cap, p.City, p.Country).Scan(&pd.CreatedAt, &pd.UpdatedAt)
|
||||
if isForeignKeyViolation(err) {
|
||||
return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "failed to save personal data")
|
||||
}
|
||||
return &pd, nil
|
||||
}
|
||||
|
||||
// isUniqueViolation reports whether err is a Postgres unique constraint violation.
|
||||
func isUniqueViolation(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
return errors.As(err, &pgErr) && pgErr.Code == "23505"
|
||||
}
|
||||
|
||||
// isForeignKeyViolation reports whether err is a Postgres foreign key constraint violation.
|
||||
func isForeignKeyViolation(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
return errors.As(err, &pgErr) && pgErr.Code == "23503"
|
||||
}
|
||||
23
admin/middleware.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
authsvc "encore.app/auth"
|
||||
"encore.dev/beta/auth"
|
||||
"encore.dev/beta/errs"
|
||||
"encore.dev/middleware"
|
||||
)
|
||||
|
||||
// RequireAdmin ensures the caller is logged in and has the admin role
|
||||
// before any endpoint in this service runs.
|
||||
//
|
||||
//encore:middleware target=all
|
||||
func RequireAdmin(req middleware.Request, next middleware.Next) middleware.Response {
|
||||
if _, ok := auth.UserID(); !ok {
|
||||
return middleware.Response{Err: &errs.Error{Code: errs.Unauthenticated, Message: "must be logged in"}}
|
||||
}
|
||||
data, _ := auth.Data().(*authsvc.AuthData)
|
||||
if data == nil || !data.Role.IsAdmin() {
|
||||
return middleware.Response{Err: &errs.Error{Code: errs.PermissionDenied, Message: "admin role required"}}
|
||||
}
|
||||
return next(req)
|
||||
}
|
||||
24
admin/system.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
authsvc "encore.app/auth"
|
||||
profilessvc "encore.app/profiles"
|
||||
)
|
||||
|
||||
type SystemOptionsResponse struct {
|
||||
Roles []authsvc.RoleOption `json:"roles"`
|
||||
Statuses []profilessvc.StatusOption `json:"statuses"`
|
||||
}
|
||||
|
||||
// GetSystemOptions returns the valid roles and profile statuses, each with a
|
||||
// display name and underlying value, for use in admin UI dropdowns.
|
||||
//
|
||||
//encore:api auth method=GET path=/admin/system/options
|
||||
func GetSystemOptions(ctx context.Context) (*SystemOptionsResponse, error) {
|
||||
return &SystemOptionsResponse{
|
||||
Roles: authsvc.Roles(),
|
||||
Statuses: profilessvc.Statuses(),
|
||||
}, nil
|
||||
}
|
||||
12
assicurazione.md
Normal file
@@ -0,0 +1,12 @@
|
||||
N. sinistro 2026 7196966
|
||||
|
||||
1. descrizione dell'incidente
|
||||
|
||||
procedevo salendo verso Breno sulla strada cantonale (classificata strada di montagna). Affrontando la curva al tornante che prima di arrivare al negozio "Bottega Breno". La careggiata in questo tornante non ofre visibilità è necessario osservare la massima prudenza, cosa che percorrendo regolarmente questa strada applico regolarmente. Ho affrontato la curva come sempre con prudenza e tenendo la destra in modo adeguato. Essendo la careggiata non larga a sufficienza per incrociare due veicoli senza che uno si debba fermare. Inoltre visto la dimensione ridotta non c'è la segalazione di careggiata. (linea bianca al centro). Il veicolo che procedeva in discesa non mi ha concesso la precedenza, non si è fermato e mi ha urtato la parte frontale sinistra.
|
||||
|
||||
allego alla risposta alcune fotografie indicative e lo schizzo.
|
||||
|
||||
2. secondo lei, a chi è attribuibile la colpa e perche?
|
||||
a mio avviso la colpa è attribuibile al vostro assicurato. Avrebbe dovuto prestare prudenza e rispettare la precedenza.
|
||||
|
||||
Manno 3.07.2026
|
||||
110
auth/auth.go
Normal file
@@ -0,0 +1,110 @@
|
||||
// Service auth owns authentication: credentials, sessions, login and logout.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"encore.dev/beta/auth"
|
||||
"encore.dev/beta/errs"
|
||||
"encore.dev/storage/sqldb"
|
||||
"encore.dev/types/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var db = sqldb.NewDatabase("auth", sqldb.DatabaseConfig{
|
||||
Migrations: "./migrations",
|
||||
})
|
||||
|
||||
var profilesDB = sqldb.Named("profiles")
|
||||
|
||||
// PasswordPepper is mixed into every password before hashing, on top of
|
||||
// bcrypt's per-password salt, so leaked password hashes are useless without it.
|
||||
var secrets struct {
|
||||
PasswordPepper string
|
||||
}
|
||||
|
||||
const sessionTTL = 7 * 24 * time.Hour
|
||||
|
||||
type AuthParams struct {
|
||||
SessionCookie *http.Cookie `cookie:"session"`
|
||||
Authorization string `header:"Authorization"`
|
||||
}
|
||||
|
||||
// AuthData is exposed to authenticated handlers via auth.Data().
|
||||
type AuthData struct {
|
||||
SessionToken uuid.UUID
|
||||
Role role
|
||||
}
|
||||
|
||||
// AuthHandler authenticates a request by looking up its session token — taken
|
||||
// from the session cookie, or as a fallback the "Bearer <token>" Authorization
|
||||
// header — against active sessions.
|
||||
//
|
||||
//encore:authhandler
|
||||
func AuthHandler(ctx context.Context, p *AuthParams) (auth.UID, *AuthData, error) {
|
||||
token, ok := sessionToken(p)
|
||||
if !ok {
|
||||
return "", nil, &errs.Error{Code: errs.Unauthenticated, Message: "missing session credentials"}
|
||||
}
|
||||
|
||||
var userID uuid.UUID
|
||||
err := db.QueryRow(ctx, `
|
||||
SELECT user_id FROM sessions WHERE token = $1 AND expires_at > NOW()
|
||||
`, token).Scan(&userID)
|
||||
if errors.Is(err, sqldb.ErrNoRows) {
|
||||
return "", nil, &errs.Error{Code: errs.Unauthenticated, Message: "invalid or expired session"}
|
||||
}
|
||||
if err != nil {
|
||||
return "", nil, errs.WrapCode(err, errs.Internal, "failed to validate session")
|
||||
}
|
||||
|
||||
var roleStr string
|
||||
err = profilesDB.QueryRow(ctx, `
|
||||
SELECT role FROM user_profiles WHERE user_id = $1
|
||||
`, userID).Scan(&roleStr)
|
||||
if err != nil && !errors.Is(err, sqldb.ErrNoRows) {
|
||||
return "", nil, errs.WrapCode(err, errs.Internal, "failed to load user role")
|
||||
}
|
||||
r := role(roleStr)
|
||||
if !r.IsValid() {
|
||||
r = roleUser
|
||||
}
|
||||
|
||||
return auth.UID(userID.String()), &AuthData{SessionToken: token, Role: r}, nil
|
||||
}
|
||||
|
||||
func sessionToken(p *AuthParams) (uuid.UUID, bool) {
|
||||
if p.SessionCookie != nil {
|
||||
if id, err := uuid.FromString(p.SessionCookie.Value); err == nil {
|
||||
return id, true
|
||||
}
|
||||
}
|
||||
if rest, ok := strings.CutPrefix(p.Authorization, "Bearer "); ok {
|
||||
if id, err := uuid.FromString(rest); err == nil {
|
||||
return id, true
|
||||
}
|
||||
}
|
||||
return uuid.Nil, false
|
||||
}
|
||||
|
||||
// pepperedPassword pre-hashes the password together with the application-wide
|
||||
// pepper secret using SHA-256, producing a fixed-size digest. This both mixes
|
||||
// in the pepper and keeps the input to bcrypt within its 72-byte limit
|
||||
// regardless of the original password's length.
|
||||
func pepperedPassword(password string) []byte {
|
||||
sum := sha256.Sum256([]byte(password + secrets.PasswordPepper))
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
func hashPassword(password string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword(pepperedPassword(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(hash), nil
|
||||
}
|
||||
111
auth/credentials.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"encore.dev/beta/auth"
|
||||
"encore.dev/beta/errs"
|
||||
"encore.dev/storage/sqldb"
|
||||
"encore.dev/types/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type RegisterParams struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Password string `json:"password" encore:"sensitive"`
|
||||
}
|
||||
|
||||
// Register stores the initial password hash for a newly created profile.
|
||||
// Called directly (service-to-service) by profiles.Insert during registration,
|
||||
// since that flow needs to know immediately whether credential setup succeeded.
|
||||
//
|
||||
//encore:api private method=POST path=/auth/credentials
|
||||
func Register(ctx context.Context, p *RegisterParams) error {
|
||||
hash, err := hashPassword(p.Password)
|
||||
if err != nil {
|
||||
return errs.WrapCode(err, errs.Internal, "failed to hash password")
|
||||
}
|
||||
_, err = db.Exec(ctx, `
|
||||
INSERT INTO credentials (user_id, password_hash) VALUES ($1, $2)
|
||||
`, p.UserID, hash)
|
||||
if err != nil {
|
||||
return errs.WrapCode(err, errs.Internal, "failed to store credentials")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type SetPasswordParams struct {
|
||||
CurrentPassword string `json:"current_password" encore:"sensitive"`
|
||||
Password string `json:"password" encore:"sensitive"`
|
||||
}
|
||||
|
||||
type SetUserPasswordParams struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Password string `json:"password" encore:"sensitive"`
|
||||
}
|
||||
|
||||
// SetPassword changes the password for the authenticated user.
|
||||
//
|
||||
//encore:api auth method=PUT path=/auth/password
|
||||
func SetPassword(ctx context.Context, p *SetPasswordParams) error {
|
||||
uid, ok := auth.UserID()
|
||||
if !ok {
|
||||
return &errs.Error{Code: errs.Unauthenticated, Message: "missing auth"}
|
||||
}
|
||||
userID, err := uuid.FromString(string(uid))
|
||||
if err != nil {
|
||||
return errs.WrapCode(err, errs.Internal, "invalid user id")
|
||||
}
|
||||
|
||||
var currentHash string
|
||||
err = db.QueryRow(ctx, `
|
||||
SELECT password_hash FROM credentials WHERE user_id = $1
|
||||
`, userID).Scan(¤tHash)
|
||||
if errors.Is(err, sqldb.ErrNoRows) {
|
||||
return &errs.Error{Code: errs.NotFound, Message: "credentials not found"}
|
||||
}
|
||||
if err != nil {
|
||||
return errs.WrapCode(err, errs.Internal, "failed to fetch credentials")
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(currentHash), pepperedPassword(p.CurrentPassword)); err != nil {
|
||||
return &errs.Error{Code: errs.Unauthenticated, Message: "invalid current password"}
|
||||
}
|
||||
|
||||
hash, err := hashPassword(p.Password)
|
||||
if err != nil {
|
||||
return errs.WrapCode(err, errs.Internal, "failed to hash password")
|
||||
}
|
||||
_, err = db.Exec(ctx, `
|
||||
UPDATE credentials SET password_hash = $2, updated_at = NOW() WHERE user_id = $1
|
||||
`, userID, hash)
|
||||
if err != nil {
|
||||
return errs.WrapCode(err, errs.Internal, "failed to update password")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetUserPassword changes a user's password from trusted internal flows.
|
||||
//
|
||||
//encore:api private method=PUT path=/auth/internal/users/:userID/password
|
||||
func SetUserPassword(ctx context.Context, userID uuid.UUID, p *SetUserPasswordParams) error {
|
||||
if p.UserID != uuid.Nil && p.UserID != userID {
|
||||
return &errs.Error{Code: errs.InvalidArgument, Message: "user id mismatch"}
|
||||
}
|
||||
|
||||
hash, err := hashPassword(p.Password)
|
||||
if err != nil {
|
||||
return errs.WrapCode(err, errs.Internal, "failed to hash password")
|
||||
}
|
||||
|
||||
res, err := db.Exec(ctx, `
|
||||
UPDATE credentials SET password_hash = $2, updated_at = NOW() WHERE user_id = $1
|
||||
`, userID, hash)
|
||||
if err != nil {
|
||||
return errs.WrapCode(err, errs.Internal, "failed to update password")
|
||||
}
|
||||
if res.RowsAffected() == 0 {
|
||||
return &errs.Error{Code: errs.NotFound, Message: "credentials not found"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
119
auth/login.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"encore.dev/beta/auth"
|
||||
"encore.dev/beta/errs"
|
||||
"encore.dev/pubsub"
|
||||
"encore.dev/storage/sqldb"
|
||||
"encore.dev/types/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type LoginParams struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Password string `json:"password" encore:"sensitive"`
|
||||
}
|
||||
|
||||
type LoginResponse struct {
|
||||
Token uuid.UUID `json:"token"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// Login verifies a user's password and issues a new session token.
|
||||
//
|
||||
// It is private — external clients authenticate via the public
|
||||
// profiles.Login, which talks to this service asynchronously over Pub/Sub
|
||||
// (see LoginRequests/LoginResults below) rather than calling it directly.
|
||||
//
|
||||
//encore:api private method=POST path=/auth/internal/login
|
||||
func Login(ctx context.Context, p *LoginParams) (*LoginResponse, error) {
|
||||
var hash string
|
||||
err := db.QueryRow(ctx, `
|
||||
SELECT password_hash FROM credentials WHERE user_id = $1
|
||||
`, p.UserID).Scan(&hash)
|
||||
if errors.Is(err, sqldb.ErrNoRows) {
|
||||
return nil, &errs.Error{Code: errs.Unauthenticated, Message: "invalid credentials"}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "failed to fetch credentials")
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(hash), pepperedPassword(p.Password)); err != nil {
|
||||
return nil, &errs.Error{Code: errs.Unauthenticated, Message: "invalid credentials"}
|
||||
}
|
||||
|
||||
token, err := uuid.NewV4()
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "failed to generate session token")
|
||||
}
|
||||
expiresAt := time.Now().Add(sessionTTL)
|
||||
_, err = db.Exec(ctx, `
|
||||
INSERT INTO sessions (token, user_id, expires_at) VALUES ($1, $2, $3)
|
||||
`, token, p.UserID, expiresAt)
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "failed to create session")
|
||||
}
|
||||
return &LoginResponse{Token: token, ExpiresAt: expiresAt}, nil
|
||||
}
|
||||
|
||||
// Logout revokes the session used to authenticate the current request.
|
||||
//
|
||||
//encore:api auth method=POST path=/auth/logout
|
||||
func Logout(ctx context.Context) error {
|
||||
data, _ := auth.Data().(*AuthData)
|
||||
if data == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := db.Exec(ctx, `DELETE FROM sessions WHERE token = $1`, data.SessionToken)
|
||||
if err != nil {
|
||||
return errs.WrapCode(err, errs.Internal, "failed to revoke session")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoginRequested is published by profiles.Login to ask this service to
|
||||
// authenticate a user asynchronously — the public endpoint lives in profiles,
|
||||
// but the credential check and session issuance happen here.
|
||||
type LoginRequested struct {
|
||||
RequestID uuid.UUID
|
||||
UserID uuid.UUID
|
||||
Password string `encore:"sensitive"`
|
||||
}
|
||||
|
||||
var LoginRequests = pubsub.NewTopic[*LoginRequested]("login-requests", pubsub.TopicConfig{
|
||||
DeliveryGuarantee: pubsub.AtLeastOnce,
|
||||
})
|
||||
|
||||
// LoginCompleted carries the outcome of a LoginRequested message back to
|
||||
// whichever profiles instance is waiting on the matching RequestID.
|
||||
type LoginCompleted struct {
|
||||
RequestID uuid.UUID
|
||||
OK bool
|
||||
Token uuid.UUID
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
var LoginResults = pubsub.NewTopic[*LoginCompleted]("login-results", pubsub.TopicConfig{
|
||||
DeliveryGuarantee: pubsub.AtLeastOnce,
|
||||
})
|
||||
|
||||
var _ = pubsub.NewSubscription(
|
||||
LoginRequests, "authenticate",
|
||||
pubsub.SubscriptionConfig[*LoginRequested]{
|
||||
Handler: handleLoginRequested,
|
||||
},
|
||||
)
|
||||
|
||||
func handleLoginRequested(ctx context.Context, ev *LoginRequested) error {
|
||||
result := &LoginCompleted{RequestID: ev.RequestID}
|
||||
if resp, err := Login(ctx, &LoginParams{UserID: ev.UserID, Password: ev.Password}); err == nil {
|
||||
result.OK = true
|
||||
result.Token = resp.Token
|
||||
result.ExpiresAt = resp.ExpiresAt
|
||||
}
|
||||
_, err := LoginResults.Publish(ctx, result)
|
||||
return err
|
||||
}
|
||||
15
auth/migrations/1_create_auth_tables.up.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE credentials (
|
||||
user_id UUID PRIMARY KEY,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE sessions (
|
||||
token UUID PRIMARY KEY,
|
||||
user_id UUID NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX sessions_user_id_idx ON sessions (user_id);
|
||||
43
auth/roles.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package auth
|
||||
|
||||
type role string
|
||||
|
||||
const (
|
||||
roleUser role = "user"
|
||||
roleAdmin role = "admin"
|
||||
)
|
||||
|
||||
func (r role) String() string {
|
||||
return string(r)
|
||||
}
|
||||
|
||||
func (r role) IsValid() bool {
|
||||
return r == roleUser || r == roleAdmin
|
||||
}
|
||||
|
||||
func (r role) IsAdmin() bool {
|
||||
return r == roleAdmin
|
||||
}
|
||||
|
||||
func (r role) IsUser() bool {
|
||||
return r == roleUser
|
||||
}
|
||||
|
||||
// RoleOption describes a valid role for display in admin UIs.
|
||||
type RoleOption struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// Roles returns every valid role with a display name and its underlying value.
|
||||
func Roles() []RoleOption {
|
||||
return []RoleOption{
|
||||
{Name: "User", Value: string(roleUser)},
|
||||
{Name: "Admin", Value: string(roleAdmin)},
|
||||
}
|
||||
}
|
||||
|
||||
// IsValidRole reports whether value is a valid role.
|
||||
func IsValidRole(value string) bool {
|
||||
return role(value).IsValid()
|
||||
}
|
||||
5
encore.app
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
// The app is not currently linked to the encore.dev platform.
|
||||
// Use "encore app link" to link it.
|
||||
"id": "",
|
||||
}
|
||||
7
frontend/.editorconfig
Normal file
@@ -0,0 +1,7 @@
|
||||
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue}]
|
||||
charset = utf-8
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
26
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
.DS_Store
|
||||
.thumbs.db
|
||||
node_modules
|
||||
|
||||
# .env files
|
||||
.env*
|
||||
|
||||
# Quasar core related directories
|
||||
.quasar
|
||||
/dist
|
||||
/quasar.config.*.temporary.compiled*
|
||||
|
||||
# Cordova related directories and files
|
||||
/src-cordova/node_modules
|
||||
/src-cordova/platforms
|
||||
/src-cordova/plugins
|
||||
/src-cordova/www
|
||||
|
||||
# Capacitor related directories and files
|
||||
/src-capacitor/www
|
||||
/src-capacitor/node_modules
|
||||
|
||||
# Log files
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
13
frontend/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"editorconfig.editorconfig",
|
||||
"vue.volar",
|
||||
"wayou.vscode-todo-highlight"
|
||||
],
|
||||
"unwantedRecommendations": [
|
||||
"octref.vetur",
|
||||
"hookyqr.beautify",
|
||||
"dbaeumer.jshint",
|
||||
"ms-vscode.vscode-typescript-tslint-plugin"
|
||||
]
|
||||
}
|
||||
10
frontend/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"editor.bracketPairColorization.enabled": true,
|
||||
"editor.guides.bracketPairs": true,
|
||||
"js/ts.tsdk.path": "node_modules/typescript/lib",
|
||||
"search.exclude": {
|
||||
"dist/": true,
|
||||
".quasar/": true,
|
||||
"/quasar.config.js.temporary.*": true
|
||||
}
|
||||
}
|
||||
23
frontend/README.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Quasar App (frontend)
|
||||
|
||||
## Install the dependencies
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
# or: yarn/npm/bun install
|
||||
```
|
||||
|
||||
### Start the app in development mode (HMR, error reporting, etc.)
|
||||
|
||||
```bash
|
||||
quasar dev
|
||||
```
|
||||
|
||||
### Build the app for production
|
||||
|
||||
```bash
|
||||
quasar build
|
||||
```
|
||||
|
||||
### Customize the configuration
|
||||
See [Configuring quasar.config.js](https://v2.quasar.dev/quasar-cli-vite/quasar-config-js).
|
||||
15
frontend/env.d.ts
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Add types (that are not auto-magically added by Quasar CLI already)
|
||||
* for your custom variables to avoid TypeScript errors, like dynamic
|
||||
* process.env variables or definitions in dotenv files configured ONLY
|
||||
* for the /quasar.config file itself.
|
||||
*
|
||||
* https://quasar.dev/quasar-cli-vite/handling-import-meta-env#type-inference
|
||||
*
|
||||
* @example
|
||||
* interface ImportMetaEnv {
|
||||
* readonly MY_VAR: string;
|
||||
* readonly MY_OTHER_VAR: string;
|
||||
* }
|
||||
*/
|
||||
interface ImportMetaEnv {}
|
||||
25
frontend/index.html
Normal file
@@ -0,0 +1,25 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title><%= productName %></title>
|
||||
|
||||
<meta charset="utf-8">
|
||||
<meta name="description" content="<%= productDescription %>">
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="msapplication-tap-highlight" content="no">
|
||||
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width<% if (ctx.mode.cordova || ctx.mode.capacitor) { %>, viewport-fit=cover<% } %>">
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://*.encr.app<% if (ctx.dev) { %> http://localhost:* http://127.0.0.1:*<% } %>; connect-src 'self' blob: https://*.encr.app<% if (ctx.dev) { %> http://localhost:4000 ws://localhost:*<% } %>;<% if (ctx.dev) { %> worker-src 'self' blob:;<% } %>"
|
||||
/>
|
||||
|
||||
<link rel="icon" type="image/png" sizes="128x128" href="icons/favicon-128x128.png">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="icons/favicon-96x96.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="icons/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="icons/favicon-16x16.png">
|
||||
<link rel="icon" type="image/ico" href="favicon.ico">
|
||||
</head>
|
||||
<body>
|
||||
<!-- quasar:entry-point -->
|
||||
</body>
|
||||
</html>
|
||||
45
frontend/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.0.1",
|
||||
"description": "A Quasar Project",
|
||||
"productName": "Quasar App",
|
||||
"author": "fabio <prada.fabio@gmail.com>",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "quasar dev",
|
||||
"build": "quasar build",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"zod:sync": "node tools/zod-sync.mjs",
|
||||
"postinstall": "quasar prepare --silent"
|
||||
},
|
||||
"dependencies": {
|
||||
"@quasar/extras": "^2.0.0",
|
||||
"pinia": "^3.0.4",
|
||||
"quasar": "^2.20.0",
|
||||
"vue": "^3.5.22",
|
||||
"vue-advanced-cropper": "^2.8.9",
|
||||
"vue-i18n": "^11.4.6",
|
||||
"vue-router": "^5.0.6",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@quasar/app-vite": "^3.0.0-rc.2",
|
||||
"@types/node": "^22.19.11",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"typescript": "^6.0.0",
|
||||
"vue-tsc": "^3.3.3"
|
||||
},
|
||||
"keywords": [
|
||||
"quasar",
|
||||
"quasar-app",
|
||||
"quasar-cli",
|
||||
"quasar-app-vite",
|
||||
"vite",
|
||||
"vue",
|
||||
"vuejs"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 26 || ^24 || ^22.12"
|
||||
}
|
||||
}
|
||||
3035
frontend/pnpm-lock.yaml
generated
Normal file
10
frontend/pnpm-workspace.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
# https://pnpm.io/settings
|
||||
|
||||
allowBuilds:
|
||||
'@parcel/watcher': true
|
||||
core-js: true
|
||||
electron-winstaller: true
|
||||
esbuild: true
|
||||
lightningcss: true
|
||||
rolldown: true
|
||||
unrs-resolver: true
|
||||
29
frontend/postcss.config.js
Normal file
@@ -0,0 +1,29 @@
|
||||
// https://github.com/michael-ciniawsky/postcss-load-config
|
||||
|
||||
import autoprefixer from 'autoprefixer'
|
||||
// import rtlcss from 'postcss-rtlcss'
|
||||
|
||||
export default {
|
||||
plugins: [
|
||||
// https://github.com/postcss/autoprefixer
|
||||
autoprefixer({
|
||||
overrideBrowserslist: [
|
||||
'last 4 Chrome versions',
|
||||
'last 4 Firefox versions',
|
||||
'last 4 Edge versions',
|
||||
'last 4 Safari versions',
|
||||
'last 4 Android versions',
|
||||
'last 4 ChromeAndroid versions',
|
||||
'last 4 FirefoxAndroid versions',
|
||||
'last 4 iOS versions'
|
||||
]
|
||||
}),
|
||||
|
||||
// https://github.com/elchininet/postcss-rtlcss
|
||||
// If you want to support RTL css, then
|
||||
// 1. yarn/pnpm/bun/npm install postcss-rtlcss
|
||||
// 2. optionally set quasar.config.js > framework > lang to an RTL language
|
||||
// 3. uncomment the following line (and its import statement above):
|
||||
// rtlcss()
|
||||
]
|
||||
}
|
||||
BIN
frontend/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
frontend/public/icons/favicon-128x128.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/public/icons/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 859 B |
BIN
frontend/public/icons/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
frontend/public/icons/favicon-96x96.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
218
frontend/quasar.config.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
// Configuration for your app
|
||||
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file
|
||||
|
||||
import { defineConfig } from '#q-app';
|
||||
|
||||
export default defineConfig((/* ctx */) => {
|
||||
return {
|
||||
// https://v2.quasar.dev/quasar-cli-vite/prefetch-feature
|
||||
// preFetch: true,
|
||||
|
||||
// app boot file (/src/boot)
|
||||
// --> boot files are part of "main.js"
|
||||
// https://v2.quasar.dev/quasar-cli-vite/boot-files
|
||||
boot: [
|
||||
'i18n',
|
||||
],
|
||||
|
||||
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#css
|
||||
css: [
|
||||
'app.css'
|
||||
],
|
||||
|
||||
// https://github.com/quasarframework/quasar/tree/dev/extras
|
||||
extras: [
|
||||
// 'ionicons-v4',
|
||||
// 'mdi-v7',
|
||||
// 'fontawesome-v7',
|
||||
// 'eva-icons',
|
||||
// 'themify',
|
||||
// 'line-awesome',
|
||||
// 'roboto-font-latin-ext', // this or either 'roboto-font', NEVER both!
|
||||
|
||||
'roboto-font', // optional, you are not bound to it
|
||||
'material-icons', // optional, you are not bound to it
|
||||
],
|
||||
|
||||
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#build
|
||||
build: {
|
||||
target: {
|
||||
// browser: 'baseline-widely-available',
|
||||
// node: 'node22'
|
||||
},
|
||||
|
||||
typescript: {
|
||||
strict: true,
|
||||
vueShim: true
|
||||
// extendTsConfig (tsConfig) {}
|
||||
},
|
||||
|
||||
// https://v2.quasar.dev/quasar-cli-vite/page-routing-with-vue-router#filename-based-routing
|
||||
// filenameBasedRouting: true,
|
||||
|
||||
vueRouterMode: 'hash', // available values: 'hash', 'history'
|
||||
// vueRouterBase,
|
||||
// vueDevtools,
|
||||
|
||||
// publicPath: '/',
|
||||
// define: {},
|
||||
// defineEnv: {}
|
||||
// ignorePublicFolder: true,
|
||||
// minify: false,
|
||||
// distDir
|
||||
|
||||
extendViteConf (viteConf) {
|
||||
viteConf.optimizeDeps = {
|
||||
...viteConf.optimizeDeps,
|
||||
include: [
|
||||
...(viteConf.optimizeDeps?.include ?? []),
|
||||
'zod',
|
||||
'vue-i18n',
|
||||
],
|
||||
};
|
||||
},
|
||||
// viteVuePluginOptions: {},
|
||||
|
||||
// vitePlugins: [
|
||||
// [ 'package-name', { ..pluginOptions.. }, { server: true, client: true } ]
|
||||
// ]
|
||||
},
|
||||
|
||||
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#devserver
|
||||
devServer: {
|
||||
// https: true,
|
||||
open: true // opens browser window automatically
|
||||
},
|
||||
|
||||
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#framework
|
||||
framework: {
|
||||
config: {},
|
||||
|
||||
// iconSet: 'material-icons', // Quasar icon set
|
||||
// lang: 'en-US', // Quasar language pack
|
||||
|
||||
// For special cases outside of where the auto-import strategy can have an impact
|
||||
// (like functional components as one of the examples),
|
||||
// you can manually specify Quasar components/directives to be available everywhere:
|
||||
//
|
||||
// components: [],
|
||||
// directives: [],
|
||||
|
||||
// Quasar plugins
|
||||
plugins: ['Notify']
|
||||
},
|
||||
|
||||
// animations: 'all', // --- includes all animations
|
||||
// https://v2.quasar.dev/options/animations
|
||||
animations: [],
|
||||
|
||||
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#sourcefiles
|
||||
sourceFiles: {
|
||||
// rootComponent: 'src/App.vue',
|
||||
// router: 'src/router/index',
|
||||
store: 'src/stores/index',
|
||||
// pwaRegisterServiceWorker: 'src-pwa/register-sw',
|
||||
// pwaServiceWorker: 'src-pwa/sw/custom-sw',
|
||||
// pwaManifestFile: 'src-pwa/manifest.json',
|
||||
// electronMain: 'src-electron/electron-main',
|
||||
// electronPreload: 'src-electron/electron-preload'
|
||||
// bexManifestFile: 'src-bex/manifest.json'
|
||||
},
|
||||
|
||||
// https://v2.quasar.dev/quasar-cli-vite/developing-ssr/configuring-ssr
|
||||
ssr: {
|
||||
prodPort: 3000, // The default port that the production server should use
|
||||
// (gets superseded if process.env.PORT is specified at runtime)
|
||||
|
||||
middlewares: [
|
||||
'render' // keep this as last one
|
||||
],
|
||||
|
||||
// extendSSRPackageJson (pkgJson) {},
|
||||
// extendSSRWebserverConf (rolldownConf) {},
|
||||
|
||||
// manualStoreSerialization: true,
|
||||
// manualStoreSsrContextInjection: true,
|
||||
// manualStoreHydration: true,
|
||||
// manualPostHydrationTrigger: true,
|
||||
|
||||
pwa: false
|
||||
// pwaOfflineHtmlFilename: 'offline.html', // do NOT use index.html as name!
|
||||
|
||||
// extendSSRGenerateSWOptions (cfg) {},
|
||||
// extendSSRInjectManifestOptions (cfg) {}
|
||||
},
|
||||
|
||||
// https://v2.quasar.dev/quasar-cli-vite/developing-pwa/configuring-pwa
|
||||
pwa: {
|
||||
workboxMode: 'GenerateSW' // 'GenerateSW' or 'InjectManifest'
|
||||
// swFilename: 'sw.js',
|
||||
// manifestFilename: 'manifest.json',
|
||||
// extendPWAManifestJson (json) {},
|
||||
// useCredentialsForManifestTag: true,
|
||||
// injectPWAMetaTags: false,
|
||||
// extendPWACustomSWConf (rolldownConf) {},
|
||||
// extendPWAGenerateSWOptions (cfg) {},
|
||||
// extendPWAInjectManifestOptions (cfg) {},
|
||||
// extendPWASwTsConfig (tsConfig) {}
|
||||
},
|
||||
|
||||
// https://v2.quasar.dev/quasar-cli-vite/developing-cordova-apps/configuring-cordova
|
||||
cordova: {},
|
||||
|
||||
// https://v2.quasar.dev/quasar-cli-vite/developing-capacitor-apps/configuring-capacitor
|
||||
capacitor: {
|
||||
hideSplashscreen: true
|
||||
},
|
||||
|
||||
// https://v2.quasar.dev/quasar-cli-vite/developing-electron-apps/configuring-electron
|
||||
electron: {
|
||||
// extendElectronMainConf (rolldownConf) {},
|
||||
// extendElectronPreloadConf (rolldownConf) {},
|
||||
// extendElectronPackageJson (pkgJson) {},
|
||||
|
||||
// Electron preload scripts (if any) from /src-electron, WITHOUT file extension
|
||||
preloadScripts: [ 'electron-preload' ],
|
||||
|
||||
// specify the debugging port to use for the Electron app when running in development mode
|
||||
inspectPort: 5858,
|
||||
|
||||
bundler: 'packager', // 'packager' or 'builder'
|
||||
|
||||
packager: {
|
||||
// https://github.com/electron-userland/electron-packager/blob/master/docs/api.md#options
|
||||
|
||||
// OS X / Mac App Store
|
||||
// appBundleId: '',
|
||||
// appCategoryType: '',
|
||||
// osxSign: '',
|
||||
// protocol: 'myapp://path',
|
||||
|
||||
// Windows only
|
||||
// win32metadata: { ... }
|
||||
},
|
||||
|
||||
builder: {
|
||||
// https://www.electron.build/configuration
|
||||
|
||||
appId: 'frontend'
|
||||
}
|
||||
},
|
||||
|
||||
// https://v2.quasar.dev/quasar-cli-vite/developing-browser-extensions/configuring-bex
|
||||
bex: {
|
||||
// extendBexScriptsConf (rolldownConf) {},
|
||||
// extendBexManifestJson (json) {},
|
||||
|
||||
/**
|
||||
* The list of extra scripts (js/ts) not in your bex manifest that you want to
|
||||
* compile and use in your browser extension. Maybe dynamic use them?
|
||||
*
|
||||
* Each entry in the list should be a relative filename to /src-bex/
|
||||
*
|
||||
* @example [ 'my-script.ts', 'sub-folder/my-other-script.js' ]
|
||||
*/
|
||||
extraScripts: []
|
||||
}
|
||||
}
|
||||
});
|
||||
3
frontend/src/App.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
BIN
frontend/src/assets/pexels-photo-4323307.jpg
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
15
frontend/src/assets/quasar-logo-vertical.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 356 360">
|
||||
<path
|
||||
d="M43.4 303.4c0 3.8-2.3 6.3-7.1 6.3h-15v-22h14.4c4.3 0 6.2 2.2 6.2 5.2 0 2.6-1.5 4.4-3.4 5 2.8.4 4.9 2.5 4.9 5.5zm-8-13H24.1v6.9H35c2.1 0 4-1.3 4-3.8 0-2.2-1.3-3.1-3.7-3.1zm5.1 12.6c0-2.3-1.8-3.7-4-3.7H24.2v7.7h11.7c3.4 0 4.6-1.8 4.6-4zm36.3 4v2.7H56v-22h20.6v2.7H58.9v6.8h14.6v2.3H58.9v7.5h17.9zm23-5.8v8.5H97v-8.5l-11-13.4h3.4l8.9 11 8.8-11h3.4l-10.8 13.4zm19.1-1.8V298c0-7.9 5.2-10.7 12.7-10.7 7.5 0 13 2.8 13 10.7v1.4c0 7.9-5.5 10.8-13 10.8s-12.7-3-12.7-10.8zm22.7 0V298c0-5.7-3.9-8-10-8-6 0-9.8 2.3-9.8 8v1.4c0 5.8 3.8 8.1 9.8 8.1 6 0 10-2.3 10-8.1zm37.2-11.6v21.9h-2.9l-15.8-17.9v17.9h-2.8v-22h3l15.6 18v-18h2.9zm37.9 10.2v1.3c0 7.8-5.2 10.4-12.4 10.4H193v-22h11.2c7.2 0 12.4 2.8 12.4 10.3zm-3 0c0-5.3-3.3-7.6-9.4-7.6h-8.4V307h8.4c6 0 9.5-2 9.5-7.7V298zm50.8-7.6h-9.7v19.3h-3v-19.3h-9.7v-2.6h22.4v2.6zm34.4-2.6v21.9h-3v-10.1h-16.8v10h-2.8v-21.8h2.8v9.2H296v-9.2h2.9zm34.9 19.2v2.7h-20.7v-22h20.6v2.7H316v6.8h14.5v2.3H316v7.5h17.8zM24 340.2v7.3h13.9v2.4h-14v9.6H21v-22h20v2.7H24zm41.5 11.4h-9.8v7.9H53v-22h13.3c5.1 0 8 1.9 8 6.8 0 3.7-2 6.3-5.6 7l6 8.2h-3.3l-5.8-8zm-9.8-2.6H66c3.1 0 5.3-1.5 5.3-4.7 0-3.3-2.2-4.1-5.3-4.1H55.7v8.8zm47.9 6.2H89l-2 4.3h-3.2l10.7-22.2H98l10.7 22.2h-3.2l-2-4.3zm-1-2.3l-6.3-13-6 13h12.2zm46.3-15.3v21.9H146v-17.2L135.7 358h-2.1l-10.2-15.6v17h-2.8v-21.8h3l11 16.9 11.3-17h3zm35 19.3v2.6h-20.7v-22h20.6v2.7H166v6.8h14.5v2.3H166v7.6h17.8zm47-19.3l-8.3 22h-3l-7.1-18.6-7 18.6h-3l-8.2-22h3.3L204 356l6.8-18.5h3.4L221 356l6.6-18.5h3.3zm10 11.6v-1.4c0-7.8 5.2-10.7 12.7-10.7 7.6 0 13 2.9 13 10.7v1.4c0 7.9-5.4 10.8-13 10.8-7.5 0-12.7-3-12.7-10.8zm22.8 0v-1.4c0-5.7-4-8-10-8s-9.9 2.3-9.9 8v1.4c0 5.8 3.8 8.2 9.8 8.2 6.1 0 10-2.4 10-8.2zm28.3 2.4h-9.8v7.9h-2.8v-22h13.2c5.2 0 8 1.9 8 6.8 0 3.7-2 6.3-5.6 7l6 8.2h-3.3l-5.8-8zm-9.8-2.6h10.2c3 0 5.2-1.5 5.2-4.7 0-3.3-2.1-4.1-5.2-4.1h-10.2v8.8zm40.3-1.5l-6.8 5.6v6.4h-2.9v-22h2.9v12.3l15.2-12.2h3.7l-9.9 8.1 10.3 13.8h-3.6l-8.9-12z" />
|
||||
<path fill="#050A14"
|
||||
d="M188.4 71.7a10.4 10.4 0 01-20.8 0 10.4 10.4 0 1120.8 0zM224.2 45c-2.2-3.9-5-7.5-8.2-10.7l-12 7c-3.7-3.2-8-5.7-12.6-7.3a49.4 49.4 0 00-9.7 13.9 59 59 0 0140.1 14l7.6-4.4a57 57 0 00-5.2-12.5zM178 125.1c4.5 0 9-.6 13.4-1.7v-14a40 40 0 0012.5-7.2 47.7 47.7 0 00-7.1-15.3 59 59 0 01-32.2 27.7v8.7c4.4 1.2 8.9 1.8 13.4 1.8zM131.8 45c-2.3 4-4 8.1-5.2 12.5l12 7a40 40 0 000 14.4c5.7 1.5 11.3 2 16.9 1.5a59 59 0 01-8-41.7l-7.5-4.3c-3.2 3.2-6 6.7-8.2 10.6z" />
|
||||
<path fill="#00B4FF"
|
||||
d="M224.2 98.4c2.3-3.9 4-8 5.2-12.4l-12-7a40 40 0 000-14.5c-5.7-1.5-11.3-2-16.9-1.5a59 59 0 018 41.7l7.5 4.4c3.2-3.2 6-6.8 8.2-10.7zm-92.4 0c2.2 4 5 7.5 8.2 10.7l12-7a40 40 0 0012.6 7.3c4-4.1 7.3-8.8 9.7-13.8a59 59 0 01-40-14l-7.7 4.4c1.2 4.3 3 8.5 5.2 12.4zm46.2-80c-4.5 0-9 .5-13.4 1.7V34a40 40 0 00-12.5 7.2c1.5 5.7 4 10.8 7.1 15.4a59 59 0 0132.2-27.7V20a53.3 53.3 0 00-13.4-1.8z" />
|
||||
<path fill="#00B4FF"
|
||||
d="M178 9.2a62.6 62.6 0 11-.1 125.2A62.6 62.6 0 01178 9.2m0-9.2a71.7 71.7 0 100 143.5A71.7 71.7 0 00178 0z" />
|
||||
<path fill="#050A14"
|
||||
d="M96.6 212v4.3c-9.2-.8-15.4-5.8-15.4-17.8V180h4.6v18.4c0 8.6 4 12.6 10.8 13.5zm16-31.9v18.4c0 8.9-4.3 12.8-10.9 13.5v4.4c9.2-.7 15.5-5.6 15.5-18v-18.3h-4.7zM62.2 199v-2.2c0-12.7-8.8-17.4-21-17.4-12.1 0-20.7 4.7-20.7 17.4v2.2c0 12.8 8.6 17.6 20.7 17.6 1.5 0 3-.1 4.4-.3l11.8 6.2 2-3.3-8.2-4-6.4-3.1a32 32 0 01-3.6.2c-9.8 0-16-3.9-16-13.3v-2.2c0-9.3 6.2-13.1 16-13.1 9.9 0 16.3 3.8 16.3 13.1v2.2c0 5.3-2.1 8.7-5.6 10.8l4.8 2.4c3.4-2.8 5.5-7 5.5-13.2zM168 215.6h5.1L156 179.7h-4.8l17 36zM143 205l7.4-15.7-2.4-5-15.1 31.4h5.1l3.3-7h18.3l-1.8-3.7H143zm133.7 10.7h5.2l-17.3-35.9h-4.8l17 36zm-25-10.7l7.4-15.7-2.4-5-15.1 31.4h5.1l3.3-7h18.3l-1.7-3.7h-14.8zm73.8-2.5c6-1.2 9-5.4 9-11.4 0-8-4.5-10.9-12.9-10.9h-21.4v35.5h4.6v-31.3h16.5c5 0 8.5 1.4 8.5 6.7 0 5.2-3.5 7.7-8.5 7.7h-11.4v4.1h10.7l9.3 12.8h5.5l-9.9-13.2zm-117.4 9.9c-9.7 0-14.7-2.5-18.6-6.3l-2.2 3.8c5.1 5 11 6.7 21 6.7 1.6 0 3.1-.1 4.6-.3l-1.9-4h-3zm18.4-7c0-6.4-4.7-8.6-13.8-9.4l-10.1-1c-6.7-.7-9.3-2.2-9.3-5.6 0-2.5 1.4-4 4.6-5l-1.8-3.8c-4.7 1.4-7.5 4.2-7.5 8.9 0 5.2 3.4 8.7 13 9.6l11.3 1.2c6.4.6 8.9 2 8.9 5.4 0 2.7-2.1 4.7-6 5.8l1.8 3.9c5.3-1.6 8.9-4.7 8.9-10zm-20.3-21.9c7.9 0 13.3 1.8 18.1 5.7l1.8-3.9a30 30 0 00-19.6-5.9c-2 0-4 .1-5.7.3l1.9 4 3.5-.2z" />
|
||||
<path fill="#00B4FF"
|
||||
d="M.5 251.9c29.6-.5 59.2-.8 88.8-1l88.7-.3 88.7.3 44.4.4 44.4.6-44.4.6-44.4.4-88.7.3-88.7-.3a7981 7981 0 01-88.8-1z" />
|
||||
<path fill="none" d="M-565.2 324H-252v15.8h-313.2z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
0
frontend/src/boot/.gitkeep
Normal file
6
frontend/src/boot/i18n.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { App } from 'vue';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
export default ({ app }: { app: App<Element> }) => {
|
||||
app.use(i18n);
|
||||
};
|
||||
240
frontend/src/components/AvatarUpload.vue
Normal file
@@ -0,0 +1,240 @@
|
||||
<template>
|
||||
<div class="row items-center q-gutter-md">
|
||||
<q-avatar size="64px">
|
||||
<img v-if="modelValue" :src="modelValue" />
|
||||
<q-icon v-else name="person" size="40px" />
|
||||
</q-avatar>
|
||||
|
||||
<div class="row items-center q-gutter-xs">
|
||||
<q-btn round flat color="primary" icon="upload" :aria-label="t('actions.chooseFile')" @click="pickFile">
|
||||
<q-tooltip>{{ t('actions.chooseFile') }}</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn round flat color="primary" icon="photo_camera" :aria-label="t('actions.camera')" @click="openCamera">
|
||||
<q-tooltip>{{ t('actions.camera') }}</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="modelValue"
|
||||
round
|
||||
flat
|
||||
color="warning"
|
||||
icon="delete"
|
||||
:aria-label="t('actions.remove')"
|
||||
@click="$emit('update:modelValue', '')"
|
||||
>
|
||||
<q-tooltip>{{ t('actions.remove') }}</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
|
||||
<input ref="fileInput" type="file" accept="image/*" class="hidden" @change="onFileSelected" />
|
||||
|
||||
<q-dialog v-model="cameraOpen" @hide="stopCamera">
|
||||
<q-card style="min-width: 350px; max-width: 90vw;">
|
||||
<q-card-section>
|
||||
<div class="text-h6">{{ t('actions.camera') }}</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section>
|
||||
<video
|
||||
ref="videoRef"
|
||||
class="camera-preview"
|
||||
autoplay
|
||||
muted
|
||||
playsinline
|
||||
/>
|
||||
<div v-if="cameraError" class="text-negative text-caption q-mt-sm">
|
||||
{{ cameraError }}
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="right">
|
||||
<q-btn v-close-popup flat :label="t('actions.cancel')" />
|
||||
<q-btn color="primary" :label="t('actions.takePhoto')" :disable="!cameraReady" @click="capturePhoto" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="cropperOpen" @show="ready = true" @hide="onHide">
|
||||
<q-card style="min-width: 350px; max-width: 90vw; position: relative;">
|
||||
<q-card-section>
|
||||
<div class="text-h6">{{ t('avatar.cropAvatar') }}</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section>
|
||||
<CropperImage
|
||||
v-if="source && ready"
|
||||
ref="cropperRef"
|
||||
class="cropper"
|
||||
:src="source"
|
||||
:width="220"
|
||||
:height="220"
|
||||
:show-controls="false"
|
||||
:show-preview="false"
|
||||
/>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="right">
|
||||
<q-btn v-close-popup flat :label="t('actions.cancel')" />
|
||||
<q-btn color="primary" :label="t('actions.save')" :loading="uploading" @click="cropAndUpload" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cropper {
|
||||
width: min(420px, 82vw);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: #DDD;
|
||||
}
|
||||
|
||||
.camera-preview {
|
||||
display: block;
|
||||
width: min(420px, 82vw);
|
||||
max-height: 420px;
|
||||
background: #222;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUnmount, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import CropperImage from '@/components/CropperImage.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string;
|
||||
/** Uploads the cropped image and resolves to its public URL. */
|
||||
uploader: (image: Blob) => Promise<string>;
|
||||
}>();
|
||||
const { t } = useI18n();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [url: string];
|
||||
}>();
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const cropperRef = ref<InstanceType<typeof CropperImage> | null>(null);
|
||||
const videoRef = ref<HTMLVideoElement | null>(null);
|
||||
const cropperOpen = ref(false);
|
||||
const cameraOpen = ref(false);
|
||||
const ready = ref(false);
|
||||
const uploading = ref(false);
|
||||
const cameraReady = ref(false);
|
||||
const cameraError = ref<string | null>(null);
|
||||
const source = ref<string | null>(null);
|
||||
let cameraStream: MediaStream | null = null;
|
||||
|
||||
function pickFile() {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
|
||||
function onFileSelected(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
setCropperSource(file);
|
||||
// Allow selecting the same file again later.
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
function onHide() {
|
||||
ready.value = false;
|
||||
clearCropperSource();
|
||||
}
|
||||
|
||||
async function openCamera() {
|
||||
cameraError.value = null;
|
||||
cameraReady.value = false;
|
||||
cameraOpen.value = true;
|
||||
await nextTick();
|
||||
await startCamera();
|
||||
}
|
||||
|
||||
async function startCamera() {
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
cameraError.value = t('avatar.cameraUnavailable');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
stopCamera();
|
||||
cameraStream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: 'user' },
|
||||
audio: false,
|
||||
});
|
||||
|
||||
if (!videoRef.value) return;
|
||||
videoRef.value.srcObject = cameraStream;
|
||||
await videoRef.value.play();
|
||||
cameraReady.value = true;
|
||||
} catch (err) {
|
||||
cameraError.value = err instanceof Error ? err.message : String(err);
|
||||
stopCamera();
|
||||
}
|
||||
}
|
||||
|
||||
function stopCamera() {
|
||||
cameraStream?.getTracks().forEach((track) => track.stop());
|
||||
cameraStream = null;
|
||||
cameraReady.value = false;
|
||||
|
||||
if (videoRef.value) {
|
||||
videoRef.value.srcObject = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function capturePhoto() {
|
||||
const video = videoRef.value;
|
||||
if (!video?.videoWidth || !video.videoHeight) return;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) return;
|
||||
|
||||
context.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
const blob = await new Promise<Blob | null>((resolve) => {
|
||||
canvas.toBlob((b) => resolve(b), 'image/png');
|
||||
});
|
||||
if (!blob) return;
|
||||
|
||||
cameraOpen.value = false;
|
||||
stopCamera();
|
||||
setCropperSource(blob);
|
||||
}
|
||||
|
||||
function setCropperSource(file: Blob) {
|
||||
clearCropperSource();
|
||||
source.value = URL.createObjectURL(file);
|
||||
cropperOpen.value = true;
|
||||
}
|
||||
|
||||
function clearCropperSource() {
|
||||
if (!source.value) return;
|
||||
URL.revokeObjectURL(source.value);
|
||||
source.value = null;
|
||||
}
|
||||
|
||||
async function cropAndUpload() {
|
||||
const blob = await cropperRef.value?.getCroppedBlob('image/png');
|
||||
if (!blob) return;
|
||||
uploading.value = true;
|
||||
try {
|
||||
const url = await props.uploader(blob);
|
||||
emit('update:modelValue', url);
|
||||
cropperOpen.value = false;
|
||||
} catch {
|
||||
// Errors surface through the store's shared error state.
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopCamera();
|
||||
clearCropperSource();
|
||||
});
|
||||
</script>
|
||||
480
frontend/src/components/CropperImage.vue
Normal file
@@ -0,0 +1,480 @@
|
||||
<template>
|
||||
<div class="cropper-image">
|
||||
<div
|
||||
ref="stageRef"
|
||||
class="cropper-stage"
|
||||
@wheel.prevent="zoomImage"
|
||||
@pointerdown="startImageDrag"
|
||||
@pointermove="dragImage"
|
||||
@pointerup="stopImageDrag"
|
||||
@pointercancel="stopImageDrag"
|
||||
>
|
||||
<img
|
||||
ref="imageRef"
|
||||
class="cropper-source"
|
||||
:style="imageStyle"
|
||||
:src="imageSrc"
|
||||
alt="Crop source"
|
||||
draggable="false"
|
||||
@load="updateImageLayout"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="cropper-mask"
|
||||
:style="selectionStyle"
|
||||
@pointerdown="startDrag"
|
||||
@pointermove="dragSelection"
|
||||
@pointerup="stopDrag"
|
||||
@pointercancel="stopDrag"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="showControls" class="cropper-controls">
|
||||
<button type="button" @click="cropImage">{{ t('actions.crop') }}</button>
|
||||
</div>
|
||||
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
class="cropper-preview"
|
||||
:class="{ 'cropper-preview--hidden': !showPreview }"
|
||||
:width="width"
|
||||
:height="height"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import cropperImageUrl from '@/assets/pexels-photo-4323307.jpg';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
src?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
showControls?: boolean;
|
||||
showPreview?: boolean;
|
||||
}>(),
|
||||
{
|
||||
src: cropperImageUrl,
|
||||
width: 45,
|
||||
height: 45,
|
||||
showControls: true,
|
||||
showPreview: true,
|
||||
},
|
||||
);
|
||||
|
||||
const imageSrc = computed(() => props.src);
|
||||
const stageRef = ref<HTMLDivElement | null>(null);
|
||||
const imageRef = ref<HTMLImageElement | null>(null);
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let baseImageScale = 1;
|
||||
|
||||
const imageLayout = reactive({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
zoom: 1,
|
||||
});
|
||||
|
||||
const selection = reactive({
|
||||
x: 20,
|
||||
y: 15,
|
||||
width: 45,
|
||||
height: 45,
|
||||
});
|
||||
|
||||
const dragState = reactive({
|
||||
active: false,
|
||||
pointerId: 0,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
selectionX: 0,
|
||||
selectionY: 0,
|
||||
});
|
||||
|
||||
const imageDragState = reactive({
|
||||
active: false,
|
||||
pointerId: 0,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
imageLeft: 0,
|
||||
imageTop: 0,
|
||||
});
|
||||
|
||||
const maxX = computed(() => 100 - selection.width);
|
||||
const maxY = computed(() => 100 - selection.height);
|
||||
|
||||
const imageStyle = computed(() => ({
|
||||
left: `${imageLayout.left}px`,
|
||||
top: `${imageLayout.top}px`,
|
||||
width: `${imageLayout.width}px`,
|
||||
height: `${imageLayout.height}px`,
|
||||
}));
|
||||
|
||||
const selectionStyle = computed(() => ({
|
||||
left: `${selection.x}%`,
|
||||
top: `${selection.y}%`,
|
||||
width: `${selection.width}%`,
|
||||
height: `${selection.height}%`,
|
||||
}));
|
||||
|
||||
watch(
|
||||
() => [selection.x, selection.y, selection.width, selection.height],
|
||||
() => {
|
||||
selection.x = Math.min(selection.x, maxX.value);
|
||||
selection.y = Math.min(selection.y, maxY.value);
|
||||
cropImage();
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [props.width, props.height],
|
||||
() => {
|
||||
updateSelectionSizeFromProps();
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.src,
|
||||
async () => {
|
||||
imageLayout.zoom = 1;
|
||||
imageLayout.width = 0;
|
||||
imageLayout.height = 0;
|
||||
await nextTick();
|
||||
updateImageLayout();
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
updateImageLayout();
|
||||
|
||||
if (stageRef.value) {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
updateImageLayout();
|
||||
});
|
||||
resizeObserver.observe(stageRef.value);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect();
|
||||
});
|
||||
|
||||
function cropImage() {
|
||||
const image = imageRef.value;
|
||||
const canvas = canvasRef.value;
|
||||
const stage = stageRef.value;
|
||||
if (
|
||||
!image ||
|
||||
!canvas ||
|
||||
!stage ||
|
||||
!image.naturalWidth ||
|
||||
!image.naturalHeight ||
|
||||
!imageLayout.width ||
|
||||
!imageLayout.height
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stageRect = stage.getBoundingClientRect();
|
||||
const maskLeft = (selection.x / 100) * stageRect.width;
|
||||
const maskTop = (selection.y / 100) * stageRect.height;
|
||||
const maskWidth = (selection.width / 100) * stageRect.width;
|
||||
const maskHeight = (selection.height / 100) * stageRect.height;
|
||||
|
||||
const sx = ((maskLeft - imageLayout.left) / imageLayout.width) * image.naturalWidth;
|
||||
const sy = ((maskTop - imageLayout.top) / imageLayout.height) * image.naturalHeight;
|
||||
const sw = (maskWidth / imageLayout.width) * image.naturalWidth;
|
||||
const sh = (maskHeight / imageLayout.height) * image.naturalHeight;
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) return;
|
||||
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.drawImage(image, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
async function getCroppedBlob(type = 'image/png', quality?: number): Promise<Blob | null> {
|
||||
cropImage();
|
||||
const canvas = canvasRef.value;
|
||||
if (!canvas) return null;
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
canvas.toBlob((blob) => resolve(blob), type, quality);
|
||||
});
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
cropImage,
|
||||
getCroppedBlob,
|
||||
});
|
||||
|
||||
function updateImageLayout() {
|
||||
const image = imageRef.value;
|
||||
const stage = stageRef.value;
|
||||
if (!image || !stage || !image.naturalWidth || !image.naturalHeight) return;
|
||||
|
||||
const rect = stage.getBoundingClientRect();
|
||||
if (!rect.width || !rect.height) return;
|
||||
|
||||
const hadLayout = imageLayout.width > 0 && imageLayout.height > 0;
|
||||
const visibleCenterX = hadLayout ? rect.width / 2 - imageLayout.left : 0;
|
||||
const visibleCenterY = hadLayout ? rect.height / 2 - imageLayout.top : 0;
|
||||
const centerRatioX = hadLayout ? visibleCenterX / imageLayout.width : 0.5;
|
||||
const centerRatioY = hadLayout ? visibleCenterY / imageLayout.height : 0.5;
|
||||
|
||||
baseImageScale = Math.min(1, rect.width / image.naturalWidth, rect.height / image.naturalHeight);
|
||||
const scale = baseImageScale * imageLayout.zoom;
|
||||
imageLayout.width = image.naturalWidth * scale;
|
||||
imageLayout.height = image.naturalHeight * scale;
|
||||
imageLayout.left = hadLayout
|
||||
? rect.width / 2 - imageLayout.width * centerRatioX
|
||||
: (rect.width - imageLayout.width) / 2;
|
||||
imageLayout.top = hadLayout
|
||||
? rect.height / 2 - imageLayout.height * centerRatioY
|
||||
: (rect.height - imageLayout.height) / 2;
|
||||
|
||||
clampImagePosition();
|
||||
updateSelectionSizeFromProps();
|
||||
}
|
||||
|
||||
function zoomImage(event: WheelEvent) {
|
||||
const zoomStep = event.deltaY < 0 ? 1.1 : 0.9;
|
||||
imageLayout.zoom = clamp(imageLayout.zoom * zoomStep, 1, 6);
|
||||
updateImageLayout();
|
||||
}
|
||||
|
||||
function startDrag(event: PointerEvent) {
|
||||
const stage = stageRef.value;
|
||||
if (!stage) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const target = event.currentTarget as HTMLElement;
|
||||
target.setPointerCapture(event.pointerId);
|
||||
|
||||
dragState.active = true;
|
||||
dragState.pointerId = event.pointerId;
|
||||
dragState.startX = event.clientX;
|
||||
dragState.startY = event.clientY;
|
||||
dragState.selectionX = selection.x;
|
||||
dragState.selectionY = selection.y;
|
||||
}
|
||||
|
||||
function startImageDrag(event: PointerEvent) {
|
||||
const stage = stageRef.value;
|
||||
if (!stage || !canDragImage()) return;
|
||||
|
||||
event.preventDefault();
|
||||
stage.setPointerCapture(event.pointerId);
|
||||
|
||||
imageDragState.active = true;
|
||||
imageDragState.pointerId = event.pointerId;
|
||||
imageDragState.startX = event.clientX;
|
||||
imageDragState.startY = event.clientY;
|
||||
imageDragState.imageLeft = imageLayout.left;
|
||||
imageDragState.imageTop = imageLayout.top;
|
||||
}
|
||||
|
||||
function dragImage(event: PointerEvent) {
|
||||
if (!imageDragState.active || event.pointerId !== imageDragState.pointerId) return;
|
||||
|
||||
imageLayout.left = imageDragState.imageLeft + event.clientX - imageDragState.startX;
|
||||
imageLayout.top = imageDragState.imageTop + event.clientY - imageDragState.startY;
|
||||
clampImagePosition();
|
||||
clampSelectionToImage();
|
||||
cropImage();
|
||||
}
|
||||
|
||||
function stopImageDrag(event: PointerEvent) {
|
||||
const stage = stageRef.value;
|
||||
if (!stage || !imageDragState.active || event.pointerId !== imageDragState.pointerId) return;
|
||||
|
||||
if (stage.hasPointerCapture(event.pointerId)) {
|
||||
stage.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
imageDragState.active = false;
|
||||
}
|
||||
|
||||
function dragSelection(event: PointerEvent) {
|
||||
const stage = stageRef.value;
|
||||
if (!dragState.active || event.pointerId !== dragState.pointerId || !stage) return;
|
||||
|
||||
const rect = stage.getBoundingClientRect();
|
||||
const dx = ((event.clientX - dragState.startX) / rect.width) * 100;
|
||||
const dy = ((event.clientY - dragState.startY) / rect.height) * 100;
|
||||
|
||||
selection.x = clamp(dragState.selectionX + dx, 0, maxX.value);
|
||||
selection.y = clamp(dragState.selectionY + dy, 0, maxY.value);
|
||||
clampSelectionToImage();
|
||||
}
|
||||
|
||||
function stopDrag(event: PointerEvent) {
|
||||
if (!dragState.active || event.pointerId !== dragState.pointerId) return;
|
||||
|
||||
const target = event.currentTarget as HTMLElement;
|
||||
if (target.hasPointerCapture(event.pointerId)) {
|
||||
target.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
dragState.active = false;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
function updateSelectionSizeFromProps() {
|
||||
const rect = stageRef.value?.getBoundingClientRect();
|
||||
if (!rect?.width || !rect.height) return;
|
||||
|
||||
const maxMaskWidth = imageLayout.width ? Math.min(rect.width, imageLayout.width) : rect.width;
|
||||
const maxMaskHeight = imageLayout.height ? Math.min(rect.height, imageLayout.height) : rect.height;
|
||||
|
||||
selection.width = sizePixelsToPercent(props.width, rect.width, maxMaskWidth);
|
||||
selection.height = sizePixelsToPercent(props.height, rect.height, maxMaskHeight);
|
||||
clampSelectionToImage();
|
||||
cropImage();
|
||||
}
|
||||
|
||||
function sizePixelsToPercent(value: number, total: number, maxValue = total) {
|
||||
return (clamp(value, 10, maxValue) / total) * 100;
|
||||
}
|
||||
|
||||
function positionPixelsToPercent(value: number, total: number) {
|
||||
return (clamp(value, 0, total) / total) * 100;
|
||||
}
|
||||
|
||||
function clampSelectionToImage() {
|
||||
const rect = stageRef.value?.getBoundingClientRect();
|
||||
if (!rect?.width || !rect.height) return;
|
||||
|
||||
if (!imageLayout.width || !imageLayout.height) {
|
||||
selection.x = Math.min(selection.x, maxX.value);
|
||||
selection.y = Math.min(selection.y, maxY.value);
|
||||
return;
|
||||
}
|
||||
|
||||
const maskWidth = (selection.width / 100) * rect.width;
|
||||
const maskHeight = (selection.height / 100) * rect.height;
|
||||
const minX = clamp(imageLayout.left, 0, rect.width - maskWidth);
|
||||
const minY = clamp(imageLayout.top, 0, rect.height - maskHeight);
|
||||
const maxXPosition = clamp(
|
||||
imageLayout.left + imageLayout.width - maskWidth,
|
||||
minX,
|
||||
rect.width - maskWidth,
|
||||
);
|
||||
const maxYPosition = clamp(
|
||||
imageLayout.top + imageLayout.height - maskHeight,
|
||||
minY,
|
||||
rect.height - maskHeight,
|
||||
);
|
||||
|
||||
selection.x = positionPixelsToPercent(
|
||||
clamp((selection.x / 100) * rect.width, minX, maxXPosition),
|
||||
rect.width,
|
||||
);
|
||||
selection.y = positionPixelsToPercent(
|
||||
clamp((selection.y / 100) * rect.height, minY, maxYPosition),
|
||||
rect.height,
|
||||
);
|
||||
}
|
||||
|
||||
function canDragImage() {
|
||||
const rect = stageRef.value?.getBoundingClientRect();
|
||||
if (!rect?.width || !rect.height) return false;
|
||||
|
||||
return imageLayout.width > rect.width || imageLayout.height > rect.height;
|
||||
}
|
||||
|
||||
function clampImagePosition() {
|
||||
const rect = stageRef.value?.getBoundingClientRect();
|
||||
if (!rect?.width || !rect.height || !imageLayout.width || !imageLayout.height) return;
|
||||
|
||||
if (imageLayout.width <= rect.width) {
|
||||
imageLayout.left = (rect.width - imageLayout.width) / 2;
|
||||
} else {
|
||||
imageLayout.left = clamp(imageLayout.left, rect.width - imageLayout.width, 0);
|
||||
}
|
||||
|
||||
if (imageLayout.height <= rect.height) {
|
||||
imageLayout.top = (rect.height - imageLayout.height) / 2;
|
||||
} else {
|
||||
imageLayout.top = clamp(imageLayout.top, rect.height - imageLayout.height, 0);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cropper-image {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
width: min(640px, 90vw);
|
||||
padding: 16px;
|
||||
border: 1px solid #d0d0d0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.cropper-stage {
|
||||
position: relative;
|
||||
height: 420px;
|
||||
overflow: hidden;
|
||||
background: #ddd;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.cropper-stage:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.cropper-source {
|
||||
position: absolute;
|
||||
display: block;
|
||||
max-width: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.cropper-mask {
|
||||
position: absolute;
|
||||
border: 2px solid #1976d2;
|
||||
box-shadow: 0 0 0 9999px rgb(0 0 0 / 45%);
|
||||
cursor: move;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.cropper-controls {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cropper-controls button {
|
||||
justify-self: start;
|
||||
padding: 8px 14px;
|
||||
border: 0;
|
||||
color: #fff;
|
||||
background: #1976d2;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cropper-preview {
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
border: 1px solid #d0d0d0;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.cropper-preview--hidden {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
79
frontend/src/components/DrawerUserCard.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<q-item
|
||||
clickable
|
||||
:to="profilesStore.isAuthenticated ? '/profile' : '/login'"
|
||||
class="drawer-user-card"
|
||||
>
|
||||
<q-item-section avatar>
|
||||
<q-avatar size="48px">
|
||||
<img v-if="profile?.avatar_url" :src="profile.avatar_url" />
|
||||
<q-icon v-else name="person" size="32px" />
|
||||
</q-avatar>
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section>
|
||||
<q-item-label class="text-weight-medium">
|
||||
{{ profileName }}
|
||||
</q-item-label>
|
||||
<q-item-label v-if="profile?.email" caption>
|
||||
{{ profile.email }}
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section v-if="profilesStore.isAuthenticated" side>
|
||||
<div class="column items-center">
|
||||
<q-btn
|
||||
flat
|
||||
round
|
||||
dense
|
||||
icon="edit"
|
||||
:aria-label="t('actions.editProfile')"
|
||||
@click.stop.prevent="editProfile"
|
||||
>
|
||||
<q-tooltip>{{ t('actions.editProfile') }}</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
round
|
||||
dense
|
||||
icon="logout"
|
||||
:aria-label="t('nav.logoutAction')"
|
||||
@click.stop.prevent="logout"
|
||||
>
|
||||
<q-tooltip>{{ t('nav.logoutAction') }}</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useProfilesStore } from '@/stores/profiles-store';
|
||||
|
||||
const profilesStore = useProfilesStore();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const profile = computed(() => profilesStore.profile);
|
||||
const profileName = computed(() => {
|
||||
if (!profilesStore.isAuthenticated) return t('nav.login');
|
||||
return profile.value?.display_name || profile.value?.email || '';
|
||||
});
|
||||
|
||||
async function logout() {
|
||||
profilesStore.logout();
|
||||
await router.push('/');
|
||||
}
|
||||
|
||||
async function editProfile() {
|
||||
await router.push({ path: '/profile', query: { mode: 'edit' } });
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.drawer-user-card {
|
||||
min-height: 72px;
|
||||
}
|
||||
</style>
|
||||
35
frontend/src/components/EssentialLink.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<q-item
|
||||
clickable
|
||||
tag="a"
|
||||
target="_blank"
|
||||
:href="link"
|
||||
>
|
||||
<q-item-section
|
||||
v-if="icon"
|
||||
avatar
|
||||
>
|
||||
<q-icon :name="icon" />
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section>
|
||||
<q-item-label>{{ label }}</q-item-label>
|
||||
<q-item-label caption>{{ caption }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
export interface EssentialLinkProps {
|
||||
label: string;
|
||||
caption?: string;
|
||||
link?: string;
|
||||
icon?: string;
|
||||
};
|
||||
|
||||
withDefaults(defineProps<EssentialLinkProps>(), {
|
||||
caption: '',
|
||||
link: '#',
|
||||
icon: '',
|
||||
});
|
||||
</script>
|
||||
23
frontend/src/components/ProfileStatusBadge.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<q-badge :color="statusInfo(status).color">
|
||||
{{ statusLabel }}
|
||||
</q-badge>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { statusInfo, type Status } from '@/stores/profiles-store';
|
||||
|
||||
const props = defineProps<{
|
||||
status: Status;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
const key = `status.${props.status}`;
|
||||
const translated = t(key);
|
||||
return translated === key ? t('status.unknown', { status: props.status }) : translated;
|
||||
});
|
||||
</script>
|
||||
19
frontend/src/css/app.css
Normal file
@@ -0,0 +1,19 @@
|
||||
/* app global css */
|
||||
|
||||
.q-field input:-webkit-autofill,
|
||||
.q-field input:-webkit-autofill:hover,
|
||||
.q-field input:-webkit-autofill:focus,
|
||||
.q-field textarea:-webkit-autofill,
|
||||
.q-field textarea:-webkit-autofill:hover,
|
||||
.q-field textarea:-webkit-autofill:focus {
|
||||
-webkit-box-shadow: 0 0 0 1000px #ffffff inset !important;
|
||||
box-shadow: 0 0 0 1000px #ffffff inset !important;
|
||||
-webkit-text-fill-color: currentColor !important;
|
||||
caret-color: currentColor !important;
|
||||
transition: background-color 999999s ease-out;
|
||||
}
|
||||
|
||||
.q-field:has(input:-webkit-autofill) .q-field__control,
|
||||
.q-field:has(textarea:-webkit-autofill) .q-field__control {
|
||||
background-color: #ffffff !important;
|
||||
}
|
||||
257
frontend/src/data/countries.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
export const countries = [
|
||||
{'CH' : 'Switzerland'},
|
||||
{'IT' : 'Italy'},
|
||||
{'FR' : 'France'},
|
||||
{'DE' : 'Germany'},
|
||||
{'GB' : 'United Kingdom of Great Britain and Northern Ireland (the)'},
|
||||
{'US' : 'United States of America (the)'},
|
||||
|
||||
{'AF' : 'Afghanistan'},
|
||||
{'AL' : 'Albania'},
|
||||
{'DZ' : 'Algeria'},
|
||||
{'AS' : 'American Samoa'},
|
||||
{'AD' : 'Andorra'},
|
||||
{'AO' : 'Angola'},
|
||||
{'AI' : 'Anguilla'},
|
||||
{'AQ' : 'Antarctica'},
|
||||
{'AG' : 'Antigua and Barbuda'},
|
||||
{'AR' : 'Argentina'},
|
||||
{'AM' : 'Armenia'},
|
||||
{'AW' : 'Aruba'},
|
||||
{'AU' : 'Australia'},
|
||||
{'AT' : 'Austria'},
|
||||
{'AZ' : 'Azerbaijan'},
|
||||
{'BS' : 'Bahamas (The)'},
|
||||
{'BH' : 'Bahrain'},
|
||||
{'BD' : 'Bangladesh'},
|
||||
{'BB' : 'Barbados'},
|
||||
{'BY' : 'Belarus'},
|
||||
{'BE' : 'Belgium'},
|
||||
{'BZ' : 'Belize'},
|
||||
{'BJ' : 'Benin'},
|
||||
{'BM' : 'Bermuda'},
|
||||
{'BT' : 'Bhutan'},
|
||||
{'BO' : 'Bolivia (Plurinational State of)'},
|
||||
{'BQ' : 'Bonaire, Sint Eustatius and Saba'},
|
||||
{'BA' : 'Bosnia and Herzegovina'},
|
||||
{'BW' : 'Botswana'},
|
||||
{'BV' : 'Bouvet Island'},
|
||||
{'BR' : 'Brazil'},
|
||||
{'IO' : 'British Indian Ocean Territory (the)'},
|
||||
{'BN' : 'Brunei Darussalam'},
|
||||
{'BG' : 'Bulgaria'},
|
||||
{'BF' : 'Burkina Faso'},
|
||||
{'BI' : 'Burundi'},
|
||||
{'CV' : 'Cabo Verde'},
|
||||
{'KH' : 'Cambodia'},
|
||||
{'CM' : 'Cameroon'},
|
||||
{'CA' : 'Canada'},
|
||||
{'KY' : 'Cayman Islands (the)'},
|
||||
{'CF' : 'Central African Republic (the)'},
|
||||
{'TD' : 'Chad'},
|
||||
{'CL' : 'Chile'},
|
||||
{'CN' : 'China'},
|
||||
{'CX' : 'Christmas Island'},
|
||||
{'CC' : 'Cocos (Keeling) Islands (the)'},
|
||||
{'CO' : 'Colombia'},
|
||||
{'KM' : 'Comoros (the)'},
|
||||
{'CD' : 'Congo (the Democratic Republic of the)'},
|
||||
{'CG' : 'Congo (the)'},
|
||||
{'CK' : 'Cook Islands (the)'},
|
||||
{'CR' : 'Costa Rica'},
|
||||
{'HR' : 'Croatia'},
|
||||
{'CU' : 'Cuba'},
|
||||
{'CW' : 'Curaçao'},
|
||||
{'CY' : 'Cyprus'},
|
||||
{'CZ' : 'Czechia'},
|
||||
{'CI' : "Côte d'Ivoire"},
|
||||
{'DK' : 'Denmark'},
|
||||
{'DJ' : 'Djibouti'},
|
||||
{'DM' : 'Dominica'},
|
||||
{'DO' : 'Dominican Republic (the)'},
|
||||
{'EC' : 'Ecuador'},
|
||||
{'EG' : 'Egypt'},
|
||||
{'SV' : 'El Salvador'},
|
||||
{'GQ' : 'Equatorial Guinea'},
|
||||
{'ER' : 'Eritrea'},
|
||||
{'EE' : 'Estonia'},
|
||||
{'SZ' : 'Eswatini'},
|
||||
{'ET' : 'Ethiopia'},
|
||||
{'FK' : 'Falkland Islands (the) [Malvinas]'},
|
||||
{'FO' : 'Faroe Islands (the)'},
|
||||
{'FJ' : 'Fiji'},
|
||||
{'FI' : 'Finland'},
|
||||
{'FR' : 'France'},
|
||||
{'GF' : 'French Guiana'},
|
||||
{'PF' : 'French Polynesia'},
|
||||
{'TF' : 'French Southern Territories (the)'},
|
||||
{'GA' : 'Gabon'},
|
||||
{'GM' : 'Gambia (the)'},
|
||||
{'GE' : 'Georgia'},
|
||||
|
||||
{'GH' : 'Ghana'},
|
||||
{'GI' : 'Gibraltar'},
|
||||
{'GR' : 'Greece'},
|
||||
{'GL' : 'Greenland'},
|
||||
{'GD' : 'Grenada'},
|
||||
{'GP' : 'Guadeloupe'},
|
||||
{'GU' : 'Guam'},
|
||||
{'GT' : 'Guatemala'},
|
||||
{'GG' : 'Guernsey'},
|
||||
{'GN' : 'Guinea'},
|
||||
{'GW' : 'Guinea-Bissau'},
|
||||
{'GY' : 'Guyana'},
|
||||
{'HT' : 'Haiti'},
|
||||
{'HM' : 'Heard Island and McDonald Islands'},
|
||||
{'VA' : 'Holy See (the)'},
|
||||
{'HN' : 'Honduras'},
|
||||
{'HK' : 'Hong Kong'},
|
||||
{'HU' : 'Hungary'},
|
||||
{'IS' : 'Iceland'},
|
||||
{'IN' : 'India'},
|
||||
{'ID' : 'Indonesia'},
|
||||
{'IR' : 'Iran (Islamic Republic of)'},
|
||||
{'IQ' : 'Iraq'},
|
||||
{'IE' : 'Ireland'},
|
||||
{'IM' : 'Isle of Man'},
|
||||
{'IL' : 'Israel'},
|
||||
|
||||
{'JM' : 'Jamaica'},
|
||||
{'JP' : 'Japan'},
|
||||
{'JE' : 'Jersey'},
|
||||
{'JO' : 'Jordan'},
|
||||
{'KZ' : 'Kazakhstan'},
|
||||
{'KE' : 'Kenya'},
|
||||
{'KI' : 'Kiribati'},
|
||||
{'KP' : "Korea (the Democratic People's Republic of)"},
|
||||
{'KR' : 'Korea (the Republic of)'},
|
||||
{'KW' : 'Kuwait'},
|
||||
{'KG' : 'Kyrgyzstan'},
|
||||
{'LA' : "Lao People's Democratic Republic (the)"},
|
||||
{'LV' : 'Latvia'},
|
||||
{'LB' : 'Lebanon'},
|
||||
{'LS' : 'Lesotho'},
|
||||
{'LR' : 'Liberia'},
|
||||
{'LY' : 'Libya'},
|
||||
{'LI' : 'Liechtenstein'},
|
||||
{'LT' : 'Lithuania'},
|
||||
{'LU' : 'Luxembourg'},
|
||||
{'MO' : 'Macao'},
|
||||
{'MG' : 'Madagascar'},
|
||||
{'MW' : 'Malawi'},
|
||||
{'MY' : 'Malaysia'},
|
||||
{'MV' : 'Maldives'},
|
||||
{'ML' : 'Mali'},
|
||||
{'MT' : 'Malta'},
|
||||
{'MH' : 'Marshall Islands (the)'},
|
||||
{'MQ' : 'Martinique'},
|
||||
{'MR' : 'Mauritania'},
|
||||
{'MU' : 'Mauritius'},
|
||||
{'YT' : 'Mayotte'},
|
||||
{'MX' : 'Mexico'},
|
||||
{'FM' : 'Micronesia (Federated States of)'},
|
||||
{'MD' : 'Moldova (the Republic of)'},
|
||||
{'MC' : 'Monaco'},
|
||||
{'MN' : 'Mongolia'},
|
||||
{'ME' : 'Montenegro'},
|
||||
{'MS' : 'Montserrat'},
|
||||
{'MA' : 'Morocco'},
|
||||
{'MZ' : 'Mozambique'},
|
||||
{'MM' : 'Myanmar'},
|
||||
{'NA' : 'Namibia'},
|
||||
{'NR' : 'Nauru'},
|
||||
{'NP' : 'Nepal'},
|
||||
{'NL' : 'Netherlands (Kingdom of the)'},
|
||||
{'NC' : 'New Caledonia'},
|
||||
{'NZ' : 'New Zealand'},
|
||||
{'NI' : 'Nicaragua'},
|
||||
{'NE' : 'Niger (the)'},
|
||||
{'NG' : 'Nigeria'},
|
||||
{'NU' : 'Niue'},
|
||||
{'NF' : 'Norfolk Island'},
|
||||
{'MK' : 'North Macedonia'},
|
||||
{'MP' : 'Northern Mariana Islands (the)'},
|
||||
{'NO' : 'Norway'},
|
||||
{'OM' : 'Oman'},
|
||||
{'PK' : 'Pakistan'},
|
||||
{'PW' : 'Palau'},
|
||||
{'PS' : 'Palestine, State of'},
|
||||
{'PA' : 'Panama'},
|
||||
{'PG' : 'Papua New Guinea'},
|
||||
{'PY' : 'Paraguay'},
|
||||
{'PE' : 'Peru'},
|
||||
{'PH' : 'Philippines (the)'},
|
||||
{'PN' : 'Pitcairn'},
|
||||
{'PL' : 'Poland'},
|
||||
{'PT' : 'Portugal'},
|
||||
{'PR' : 'Puerto Rico'},
|
||||
{'QA' : 'Qatar'},
|
||||
{'RO' : 'Romania'},
|
||||
{'RU' : 'Russian Federation (the)'},
|
||||
{'RW' : 'Rwanda'},
|
||||
{'RE' : 'Réunion'},
|
||||
{'BL' : 'Saint Barthélemy'},
|
||||
{'SH' : 'Saint Helena, Ascension and Tristan da Cunha'},
|
||||
{'KN' : 'Saint Kitts and Nevis'},
|
||||
{'LC' : 'Saint Lucia'},
|
||||
{'MF' : 'Saint Martin (French part)'},
|
||||
{'PM' : 'Saint Pierre and Miquelon'},
|
||||
{'VC' : 'Saint Vincent and the Grenadines'},
|
||||
{'WS' : 'Samoa'},
|
||||
{'SM' : 'San Marino'},
|
||||
{'ST' : 'Sao Tome and Principe'},
|
||||
{'SA' : 'Saudi Arabia'},
|
||||
{'SN' : 'Senegal'},
|
||||
{'RS' : 'Serbia'},
|
||||
{'SC' : 'Seychelles'},
|
||||
{'SL' : 'Sierra Leone'},
|
||||
{'SG' : 'Singapore'},
|
||||
{'SX' : 'Sint Maarten (Dutch part)'},
|
||||
{'SK' : 'Slovakia'},
|
||||
{'SI' : 'Slovenia'},
|
||||
{'SB' : 'Solomon Islands'},
|
||||
{'SO' : 'Somalia'},
|
||||
{'ZA' : 'South Africa'},
|
||||
{'GS' : 'South Georgia and the South Sandwich Islands'},
|
||||
{'SS' : 'South Sudan'},
|
||||
{'ES' : 'Spain'},
|
||||
{'LK' : 'Sri Lanka'},
|
||||
{'SD' : 'Sudan (the)'},
|
||||
{'SR' : 'Suriname'},
|
||||
{'SJ' : 'Svalbard and Jan Mayen'},
|
||||
{'SE' : 'Sweden'},
|
||||
|
||||
{'SY' : 'Syrian Arab Republic (the)'},
|
||||
{'TW' : 'Taiwan (Province of China)'},
|
||||
{'TJ' : 'Tajikistan'},
|
||||
{'TZ' : 'Tanzania, the United Republic of'},
|
||||
{'TH' : 'Thailand'},
|
||||
{'TL' : 'Timor-Leste'},
|
||||
{'TG' : 'Togo'},
|
||||
{'TK' : 'Tokelau'},
|
||||
{'TO' : 'Tonga'},
|
||||
{'TT' : 'Trinidad and Tobago'},
|
||||
{'TN' : 'Tunisia'},
|
||||
{'TM' : 'Turkmenistan'},
|
||||
{'TC' : 'Turks and Caicos Islands (the)'},
|
||||
{'TV' : 'Tuvalu'},
|
||||
{'TR' : 'Türkiye'},
|
||||
{'UG' : 'Uganda'},
|
||||
{'UA' : 'Ukraine'},
|
||||
{'AE' : 'United Arab Emirates (the)'},
|
||||
{'GB' : 'United Kingdom of Great Britain and Northern Ireland (the)'},
|
||||
{'UM' : 'United States Minor Outlying Islands (the)'},
|
||||
|
||||
{'UY' : 'Uruguay'},
|
||||
{'UZ' : 'Uzbekistan'},
|
||||
{'VU' : 'Vanuatu'},
|
||||
{'VE' : 'Venezuela (Bolivarian Republic of)'},
|
||||
{'VN' : 'Viet Nam'},
|
||||
{'VG' : 'Virgin Islands (British)'},
|
||||
{'VI' : 'Virgin Islands (U.S.)'},
|
||||
{'WF' : 'Wallis and Futuna'},
|
||||
{'EH' : 'Western Sahara*'},
|
||||
{'YE' : 'Yemen'},
|
||||
{'ZM' : 'Zambia'},
|
||||
{'ZW' : 'Zimbabwe'},
|
||||
]
|
||||
1419
frontend/src/encore/client.ts
Executable file
86
frontend/src/encore/zod.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
// Code synced from src/encore/client.ts by frontend/tools/zod-sync.mjs.
|
||||
// Existing field validators are preserved when this file is synced again.
|
||||
import { z } from 'zod';
|
||||
|
||||
export const AdminPersonalDataParamsSchema = z.object({
|
||||
first_name: z.string().min(2).max(32),
|
||||
last_name: z.string().min(2).max(32),
|
||||
address: z.string().min(5).max(32),
|
||||
cap: z.string(),
|
||||
city: z.string().min(2).max(32),
|
||||
country: z.string(),
|
||||
});
|
||||
|
||||
export const AdminProfileParamsSchema = z.object({
|
||||
display_name: z.string().min(2).max(32),
|
||||
avatar_url: z.string(),
|
||||
});
|
||||
|
||||
export const AdminRegisterParamsSchema = z.object({
|
||||
email: z.string(),
|
||||
display_name: z.string(),
|
||||
avatar_url: z.string(),
|
||||
password: z.string(),
|
||||
});
|
||||
|
||||
export const AdminUpdateProfileArtistParamsSchema = z.object({
|
||||
is_artist: z.boolean(),
|
||||
});
|
||||
|
||||
export const AdminUpdateProfilePasswordParamsSchema = z.object({
|
||||
password: z.string(),
|
||||
});
|
||||
|
||||
export const AdminUpdateProfileRoleParamsSchema = z.object({
|
||||
role: z.string(),
|
||||
});
|
||||
|
||||
export const AdminUpdateProfileStatusParamsSchema = z.object({
|
||||
status: z.number(),
|
||||
});
|
||||
|
||||
export const AuthSetPasswordParamsSchema = z.object({
|
||||
current_password: z.string(),
|
||||
password: z.string(),
|
||||
});
|
||||
|
||||
export const ProfilesLoginParamsSchema = z.object({
|
||||
user_email: z.string(),
|
||||
password: z.string(),
|
||||
});
|
||||
|
||||
export const ProfilesPersonalDataParamsSchema = z.object({
|
||||
first_name: z.string(),
|
||||
last_name: z.string(),
|
||||
address: z.string(),
|
||||
cap: z.string(),
|
||||
city: z.string(),
|
||||
country: z.string(),
|
||||
});
|
||||
|
||||
export const ProfilesProfileParamsSchema = z.object({
|
||||
display_name: z.string(),
|
||||
avatar_url: z.string(),
|
||||
});
|
||||
|
||||
export const ProfilesRegisterParamsSchema = z.object({
|
||||
email: z.string(),
|
||||
display_name: z.string(),
|
||||
avatar_url: z.string(),
|
||||
password: z.string(),
|
||||
});
|
||||
|
||||
export const RegistrationRegisterParamsSchema = z.object({
|
||||
email: z.string().email(),
|
||||
display_name: z.string().min(2).max(32),
|
||||
avatar_url: z.string(),
|
||||
password: z.string().min(8),
|
||||
});
|
||||
|
||||
export const RegistrationPasswordResetConfirmParamsSchema = z.object({
|
||||
password: z.string(),
|
||||
});
|
||||
|
||||
export const RegistrationPasswordResetRequestParamsSchema = z.object({
|
||||
email: z.string(),
|
||||
});
|
||||
688
frontend/src/i18n/index.ts
Normal file
@@ -0,0 +1,688 @@
|
||||
import { createI18n } from 'vue-i18n';
|
||||
|
||||
export type SupportedLocale = 'en' | 'it' | 'fr' | 'de' | 'es';
|
||||
|
||||
export const localeOptions: { label: string; value: SupportedLocale }[] = [
|
||||
{ label: 'English', value: 'en' },
|
||||
{ label: 'Italiano', value: 'it' },
|
||||
{ label: 'Français', value: 'fr' },
|
||||
{ label: 'Deutsch', value: 'de' },
|
||||
{ label: 'Español', value: 'es' },
|
||||
];
|
||||
|
||||
const fallbackLocale: SupportedLocale = 'en';
|
||||
const storageKey = 'app.locale';
|
||||
|
||||
const messages = {
|
||||
en: {
|
||||
app: { title: 'Encore Profiles' },
|
||||
nav: {
|
||||
menu: 'Menu',
|
||||
profiles: 'Profiles',
|
||||
myProfile: 'My profile',
|
||||
login: 'Log in',
|
||||
register: 'Register',
|
||||
logout: 'Log out ({name})',
|
||||
logoutAction: 'Log out',
|
||||
language: 'Language',
|
||||
},
|
||||
actions: {
|
||||
cancel: 'Cancel',
|
||||
save: 'Save',
|
||||
create: 'Create',
|
||||
remove: 'Remove',
|
||||
chooseFile: 'Choose file',
|
||||
camera: 'Camera',
|
||||
takePhoto: 'Take photo',
|
||||
crop: 'Crop',
|
||||
newProfile: 'New profile',
|
||||
register: 'Register',
|
||||
editProfile: 'Edit profile',
|
||||
updateRole: 'Update role',
|
||||
updateStatus: 'Update status',
|
||||
updatePassword: 'Update password',
|
||||
},
|
||||
fields: {
|
||||
email: 'Email',
|
||||
password: 'Password',
|
||||
displayName: 'Display name',
|
||||
firstName: 'First name',
|
||||
lastName: 'Last name',
|
||||
address: 'Address',
|
||||
cap: 'Postcode',
|
||||
city: 'City',
|
||||
country: 'Country',
|
||||
role: 'Role',
|
||||
status: 'Status',
|
||||
},
|
||||
avatar: {
|
||||
cropAvatar: 'Crop avatar',
|
||||
cameraUnavailable: 'Camera not available in this browser.',
|
||||
},
|
||||
admin: {
|
||||
dashboard: 'Dashboard',
|
||||
profiles: 'Profiles',
|
||||
personalData: 'Personal data',
|
||||
profile: 'Profile',
|
||||
notArtist: 'Not an artist',
|
||||
artist: 'Artist',
|
||||
columns: {
|
||||
name: 'Name',
|
||||
email: 'Email',
|
||||
role: 'Role',
|
||||
status: 'Status',
|
||||
artist: 'Artist',
|
||||
created: 'Created',
|
||||
updated: 'Updated',
|
||||
},
|
||||
notifications: {
|
||||
profileCreated: 'Profile created.',
|
||||
profileUpdated: 'Profile updated.',
|
||||
personalDataUpdated: 'Personal data updated.',
|
||||
roleUpdated: 'Role updated.',
|
||||
statusUpdated: 'Status updated.',
|
||||
passwordUpdated: 'Password updated.',
|
||||
},
|
||||
},
|
||||
profile: {
|
||||
title: 'My profile',
|
||||
avatar: 'Avatar',
|
||||
password: 'Password',
|
||||
currentPassword: 'Current password',
|
||||
newPassword: 'New password',
|
||||
confirmNewPassword: 'Confirm new password',
|
||||
passwordMismatch: 'Passwords do not match.',
|
||||
loginRequired: 'You must be logged in to view your profile.',
|
||||
},
|
||||
login: {
|
||||
title: 'Log in',
|
||||
needAccount: 'Create an account',
|
||||
forgotPassword: 'Forgot password',
|
||||
},
|
||||
passwordReset: {
|
||||
title: 'Password reset',
|
||||
newPasswordTitle: 'Choose a new password',
|
||||
sendLink: 'Send reset link',
|
||||
updatePassword: 'Update password',
|
||||
backToLogin: 'Back to log in',
|
||||
requestSent: 'If the email exists, we sent a password reset link.',
|
||||
resetDone: 'Password updated. You can now log in.',
|
||||
},
|
||||
register: {
|
||||
title: 'Create account',
|
||||
welcomeTitle: 'Welcome',
|
||||
haveAccount: 'I already have an account',
|
||||
success: 'Registration completed. You can now log in.',
|
||||
confirmationSent: 'We sent you a confirmation request. Please check your mailbox.',
|
||||
emailUnavailable: 'This email is already registered.',
|
||||
emailDomainInvalid: 'This email domain cannot receive email.',
|
||||
},
|
||||
welcome: {
|
||||
title: 'Email confirmation',
|
||||
success: 'Email confirmed for {email}.',
|
||||
missingToken: 'Missing confirmation token.',
|
||||
},
|
||||
pages: {
|
||||
home: 'Home',
|
||||
goHome: 'Go Home',
|
||||
goToIndex: 'Go to Index Page',
|
||||
goToSecond: 'Go to Second Page',
|
||||
cropperExample: 'Cropper example',
|
||||
notFound: 'Oops. Nothing here...',
|
||||
unauthorized: 'Unauthorized',
|
||||
unauthorizedHint: 'You need to log in to access this page.',
|
||||
},
|
||||
status: {
|
||||
0: 'Active',
|
||||
1: 'Inactive',
|
||||
2: 'Suspended',
|
||||
3: 'Deleted',
|
||||
4: 'Pending',
|
||||
5: 'Waiting deletion',
|
||||
6: 'Banned',
|
||||
unknown: 'Unknown ({status})',
|
||||
},
|
||||
},
|
||||
it: {
|
||||
app: { title: 'Profili Encore' },
|
||||
nav: {
|
||||
menu: 'Menu',
|
||||
profiles: 'Profili',
|
||||
myProfile: 'Il mio profilo',
|
||||
login: 'Accedi',
|
||||
register: 'Registrati',
|
||||
logout: 'Esci ({name})',
|
||||
logoutAction: 'Esci',
|
||||
language: 'Lingua',
|
||||
},
|
||||
actions: {
|
||||
cancel: 'Annulla',
|
||||
save: 'Salva',
|
||||
create: 'Crea',
|
||||
remove: 'Rimuovi',
|
||||
chooseFile: 'Scegli file',
|
||||
camera: 'Fotocamera',
|
||||
takePhoto: 'Scatta foto',
|
||||
crop: 'Ritaglia',
|
||||
newProfile: 'Nuovo profilo',
|
||||
register: 'Registrati',
|
||||
editProfile: 'Modifica profilo',
|
||||
updateRole: 'Aggiorna ruolo',
|
||||
updateStatus: 'Aggiorna stato',
|
||||
updatePassword: 'Aggiorna password',
|
||||
},
|
||||
fields: {
|
||||
email: 'Email',
|
||||
password: 'Password',
|
||||
displayName: 'Nome visualizzato',
|
||||
firstName: 'Nome',
|
||||
lastName: 'Cognome',
|
||||
address: 'Indirizzo',
|
||||
cap: 'CAP',
|
||||
city: 'Città',
|
||||
country: 'Paese',
|
||||
role: 'Ruolo',
|
||||
status: 'Stato',
|
||||
},
|
||||
avatar: {
|
||||
cropAvatar: 'Ritaglia avatar',
|
||||
cameraUnavailable: 'Fotocamera non disponibile in questo browser.',
|
||||
},
|
||||
admin: {
|
||||
dashboard: 'Dashboard',
|
||||
profiles: 'Profili',
|
||||
personalData: 'Dati personali',
|
||||
profile: 'Profilo',
|
||||
notArtist: 'Non artista',
|
||||
artist: 'Artista',
|
||||
columns: {
|
||||
name: 'Nome',
|
||||
email: 'Email',
|
||||
role: 'Ruolo',
|
||||
status: 'Stato',
|
||||
artist: 'Artista',
|
||||
created: 'Creato',
|
||||
updated: 'Aggiornato',
|
||||
},
|
||||
notifications: {
|
||||
profileCreated: 'Profilo creato.',
|
||||
profileUpdated: 'Profilo aggiornato.',
|
||||
personalDataUpdated: 'Dati personali aggiornati.',
|
||||
roleUpdated: 'Ruolo aggiornato.',
|
||||
statusUpdated: 'Stato aggiornato.',
|
||||
passwordUpdated: 'Password aggiornata.',
|
||||
},
|
||||
},
|
||||
profile: {
|
||||
title: 'Il mio profilo',
|
||||
avatar: 'Avatar',
|
||||
password: 'Password',
|
||||
currentPassword: 'Password corrente',
|
||||
newPassword: 'Nuova password',
|
||||
confirmNewPassword: 'Conferma nuova password',
|
||||
passwordMismatch: 'Le password non coincidono.',
|
||||
loginRequired: 'Devi effettuare l’accesso per visualizzare il profilo.',
|
||||
},
|
||||
login: {
|
||||
title: 'Accedi',
|
||||
needAccount: 'Crea un account',
|
||||
forgotPassword: 'Password dimenticata',
|
||||
},
|
||||
passwordReset: {
|
||||
title: 'Recupero password',
|
||||
newPasswordTitle: 'Scegli una nuova password',
|
||||
sendLink: 'Invia link di recupero',
|
||||
updatePassword: 'Aggiorna password',
|
||||
backToLogin: 'Torna al login',
|
||||
requestSent: 'Se l’email esiste, abbiamo inviato un link per reimpostare la password.',
|
||||
resetDone: 'Password aggiornata. Ora puoi accedere.',
|
||||
},
|
||||
register: {
|
||||
title: 'Crea account',
|
||||
welcomeTitle: 'Benvenuto',
|
||||
haveAccount: 'Ho già un account',
|
||||
success: 'Registrazione completata. Ora puoi accedere.',
|
||||
confirmationSent: 'Ti abbiamo inviato una richieta di conferma, consulta la tua casella di posta.',
|
||||
emailUnavailable: 'Questa email è già registrata.',
|
||||
emailDomainInvalid: 'Il dominio di questa email non può ricevere posta.',
|
||||
},
|
||||
welcome: {
|
||||
title: 'Conferma email',
|
||||
success: 'Email confermata per {email}.',
|
||||
missingToken: 'Token di conferma mancante.',
|
||||
},
|
||||
pages: {
|
||||
home: 'Home',
|
||||
goHome: 'Vai alla home',
|
||||
goToIndex: 'Vai alla pagina iniziale',
|
||||
goToSecond: 'Vai alla seconda pagina',
|
||||
cropperExample: 'Esempio cropper',
|
||||
notFound: 'Ops. Qui non c’è niente...',
|
||||
unauthorized: 'Non autorizzato',
|
||||
unauthorizedHint: 'Devi effettuare l’accesso per visualizzare questa pagina.',
|
||||
},
|
||||
status: {
|
||||
0: 'Attivo',
|
||||
1: 'Inattivo',
|
||||
2: 'Sospeso',
|
||||
3: 'Eliminato',
|
||||
4: 'In attesa',
|
||||
5: 'In attesa di eliminazione',
|
||||
6: 'Bannato',
|
||||
unknown: 'Sconosciuto ({status})',
|
||||
},
|
||||
},
|
||||
fr: {
|
||||
app: { title: 'Profils Encore' },
|
||||
nav: {
|
||||
menu: 'Menu',
|
||||
profiles: 'Profils',
|
||||
myProfile: 'Mon profil',
|
||||
login: 'Connexion',
|
||||
register: 'Inscription',
|
||||
logout: 'Déconnexion ({name})',
|
||||
logoutAction: 'Déconnexion',
|
||||
language: 'Langue',
|
||||
},
|
||||
actions: {
|
||||
cancel: 'Annuler',
|
||||
save: 'Enregistrer',
|
||||
create: 'Créer',
|
||||
remove: 'Supprimer',
|
||||
chooseFile: 'Choisir un fichier',
|
||||
camera: 'Caméra',
|
||||
takePhoto: 'Prendre une photo',
|
||||
crop: 'Recadrer',
|
||||
newProfile: 'Nouveau profil',
|
||||
register: 'S’inscrire',
|
||||
editProfile: 'Modifier le profil',
|
||||
updateRole: 'Modifier le rôle',
|
||||
updateStatus: 'Modifier le statut',
|
||||
updatePassword: 'Modifier le mot de passe',
|
||||
},
|
||||
fields: {
|
||||
email: 'Email',
|
||||
password: 'Mot de passe',
|
||||
displayName: 'Nom affiché',
|
||||
firstName: 'Prénom',
|
||||
lastName: 'Nom',
|
||||
address: 'Adresse',
|
||||
cap: 'Code postal',
|
||||
city: 'Ville',
|
||||
country: 'Pays',
|
||||
role: 'Rôle',
|
||||
status: 'Statut',
|
||||
},
|
||||
avatar: {
|
||||
cropAvatar: 'Recadrer l’avatar',
|
||||
cameraUnavailable: 'Caméra non disponible dans ce navigateur.',
|
||||
},
|
||||
admin: {
|
||||
dashboard: 'Tableau de bord',
|
||||
profiles: 'Profils',
|
||||
personalData: 'Données personnelles',
|
||||
profile: 'Profil',
|
||||
notArtist: 'Pas artiste',
|
||||
artist: 'Artiste',
|
||||
columns: {
|
||||
name: 'Nom',
|
||||
email: 'Email',
|
||||
role: 'Rôle',
|
||||
status: 'Statut',
|
||||
artist: 'Artiste',
|
||||
created: 'Créé',
|
||||
updated: 'Mis à jour',
|
||||
},
|
||||
notifications: {
|
||||
profileCreated: 'Profil créé.',
|
||||
profileUpdated: 'Profil mis à jour.',
|
||||
personalDataUpdated: 'Données personnelles mises à jour.',
|
||||
roleUpdated: 'Rôle mis à jour.',
|
||||
statusUpdated: 'Statut mis à jour.',
|
||||
passwordUpdated: 'Mot de passe mis à jour.',
|
||||
},
|
||||
},
|
||||
profile: {
|
||||
title: 'Mon profil',
|
||||
avatar: 'Avatar',
|
||||
password: 'Mot de passe',
|
||||
currentPassword: 'Mot de passe actuel',
|
||||
newPassword: 'Nouveau mot de passe',
|
||||
confirmNewPassword: 'Confirmer le nouveau mot de passe',
|
||||
passwordMismatch: 'Les mots de passe ne correspondent pas.',
|
||||
loginRequired: 'Vous devez être connecté pour voir votre profil.',
|
||||
},
|
||||
login: {
|
||||
title: 'Connexion',
|
||||
needAccount: 'Créer un compte',
|
||||
forgotPassword: 'Mot de passe oublié',
|
||||
},
|
||||
passwordReset: {
|
||||
title: 'Réinitialisation du mot de passe',
|
||||
newPasswordTitle: 'Choisir un nouveau mot de passe',
|
||||
sendLink: 'Envoyer le lien',
|
||||
updatePassword: 'Mettre à jour le mot de passe',
|
||||
backToLogin: 'Retour à la connexion',
|
||||
requestSent: 'Si l’email existe, nous avons envoyé un lien de réinitialisation.',
|
||||
resetDone: 'Mot de passe mis à jour. Vous pouvez maintenant vous connecter.',
|
||||
},
|
||||
register: {
|
||||
title: 'Créer un compte',
|
||||
welcomeTitle: 'Bienvenue',
|
||||
haveAccount: 'J’ai déjà un compte',
|
||||
success: 'Inscription terminée. Vous pouvez maintenant vous connecter.',
|
||||
confirmationSent: 'Nous vous avons envoyé une demande de confirmation. Veuillez consulter votre boîte mail.',
|
||||
emailUnavailable: 'Cet email est déjà enregistré.',
|
||||
emailDomainInvalid: 'Ce domaine email ne peut pas recevoir d’e-mails.',
|
||||
},
|
||||
welcome: {
|
||||
title: 'Confirmation email',
|
||||
success: 'Email confirmée pour {email}.',
|
||||
missingToken: 'Jeton de confirmation manquant.',
|
||||
},
|
||||
pages: {
|
||||
home: 'Accueil',
|
||||
goHome: 'Accueil',
|
||||
goToIndex: 'Aller à la page d’accueil',
|
||||
goToSecond: 'Aller à la deuxième page',
|
||||
cropperExample: 'Exemple de recadrage',
|
||||
notFound: 'Oups. Rien ici...',
|
||||
unauthorized: 'Non autorisé',
|
||||
unauthorizedHint: 'Vous devez vous connecter pour accéder à cette page.',
|
||||
},
|
||||
status: {
|
||||
0: 'Actif',
|
||||
1: 'Inactif',
|
||||
2: 'Suspendu',
|
||||
3: 'Supprimé',
|
||||
4: 'En attente',
|
||||
5: 'Suppression en attente',
|
||||
6: 'Banni',
|
||||
unknown: 'Inconnu ({status})',
|
||||
},
|
||||
},
|
||||
de: {
|
||||
app: { title: 'Encore Profile' },
|
||||
nav: {
|
||||
menu: 'Menü',
|
||||
profiles: 'Profile',
|
||||
myProfile: 'Mein Profil',
|
||||
login: 'Anmelden',
|
||||
register: 'Registrieren',
|
||||
logout: 'Abmelden ({name})',
|
||||
logoutAction: 'Abmelden',
|
||||
language: 'Sprache',
|
||||
},
|
||||
actions: {
|
||||
cancel: 'Abbrechen',
|
||||
save: 'Speichern',
|
||||
create: 'Erstellen',
|
||||
remove: 'Entfernen',
|
||||
chooseFile: 'Datei wählen',
|
||||
camera: 'Kamera',
|
||||
takePhoto: 'Foto aufnehmen',
|
||||
crop: 'Zuschneiden',
|
||||
newProfile: 'Neues Profil',
|
||||
register: 'Registrieren',
|
||||
editProfile: 'Profil bearbeiten',
|
||||
updateRole: 'Rolle ändern',
|
||||
updateStatus: 'Status ändern',
|
||||
updatePassword: 'Passwort ändern',
|
||||
},
|
||||
fields: {
|
||||
email: 'E-Mail',
|
||||
password: 'Passwort',
|
||||
displayName: 'Anzeigename',
|
||||
firstName: 'Vorname',
|
||||
lastName: 'Nachname',
|
||||
address: 'Adresse',
|
||||
cap: 'Postleitzahl',
|
||||
city: 'Stadt',
|
||||
country: 'Land',
|
||||
role: 'Rolle',
|
||||
status: 'Status',
|
||||
},
|
||||
avatar: {
|
||||
cropAvatar: 'Avatar zuschneiden',
|
||||
cameraUnavailable: 'Kamera ist in diesem Browser nicht verfügbar.',
|
||||
},
|
||||
admin: {
|
||||
dashboard: 'Dashboard',
|
||||
profiles: 'Profile',
|
||||
personalData: 'Persönliche Daten',
|
||||
profile: 'Profil',
|
||||
notArtist: 'Kein Künstler',
|
||||
artist: 'Künstler',
|
||||
columns: {
|
||||
name: 'Name',
|
||||
email: 'E-Mail',
|
||||
role: 'Rolle',
|
||||
status: 'Status',
|
||||
artist: 'Künstler',
|
||||
created: 'Erstellt',
|
||||
updated: 'Aktualisiert',
|
||||
},
|
||||
notifications: {
|
||||
profileCreated: 'Profil erstellt.',
|
||||
profileUpdated: 'Profil aktualisiert.',
|
||||
personalDataUpdated: 'Persönliche Daten aktualisiert.',
|
||||
roleUpdated: 'Rolle aktualisiert.',
|
||||
statusUpdated: 'Status aktualisiert.',
|
||||
passwordUpdated: 'Passwort aktualisiert.',
|
||||
},
|
||||
},
|
||||
profile: {
|
||||
title: 'Mein Profil',
|
||||
avatar: 'Avatar',
|
||||
password: 'Passwort',
|
||||
currentPassword: 'Aktuelles Passwort',
|
||||
newPassword: 'Neues Passwort',
|
||||
confirmNewPassword: 'Neues Passwort bestätigen',
|
||||
passwordMismatch: 'Die Passwörter stimmen nicht überein.',
|
||||
loginRequired: 'Sie müssen angemeldet sein, um Ihr Profil zu sehen.',
|
||||
},
|
||||
login: {
|
||||
title: 'Anmelden',
|
||||
needAccount: 'Konto erstellen',
|
||||
forgotPassword: 'Passwort vergessen',
|
||||
},
|
||||
passwordReset: {
|
||||
title: 'Passwort zurücksetzen',
|
||||
newPasswordTitle: 'Neues Passwort wählen',
|
||||
sendLink: 'Link senden',
|
||||
updatePassword: 'Passwort aktualisieren',
|
||||
backToLogin: 'Zur Anmeldung',
|
||||
requestSent: 'Wenn die E-Mail existiert, haben wir einen Link zum Zurücksetzen gesendet.',
|
||||
resetDone: 'Passwort aktualisiert. Sie können sich jetzt anmelden.',
|
||||
},
|
||||
register: {
|
||||
title: 'Konto erstellen',
|
||||
welcomeTitle: 'Willkommen',
|
||||
haveAccount: 'Ich habe bereits ein Konto',
|
||||
success: 'Registrierung abgeschlossen. Sie können sich jetzt anmelden.',
|
||||
confirmationSent: 'Wir haben Ihnen eine Bestätigungsanfrage gesendet. Bitte prüfen Sie Ihr Postfach.',
|
||||
emailUnavailable: 'Diese E-Mail ist bereits registriert.',
|
||||
emailDomainInvalid: 'Diese E-Mail-Domain kann keine E-Mails empfangen.',
|
||||
},
|
||||
welcome: {
|
||||
title: 'E-Mail-Bestätigung',
|
||||
success: 'E-Mail für {email} bestätigt.',
|
||||
missingToken: 'Bestätigungstoken fehlt.',
|
||||
},
|
||||
pages: {
|
||||
home: 'Startseite',
|
||||
goHome: 'Zur Startseite',
|
||||
goToIndex: 'Zur Startseite',
|
||||
goToSecond: 'Zur zweiten Seite',
|
||||
cropperExample: 'Cropper-Beispiel',
|
||||
notFound: 'Hoppla. Hier ist nichts...',
|
||||
unauthorized: 'Nicht autorisiert',
|
||||
unauthorizedHint: 'Sie müssen angemeldet sein, um diese Seite aufzurufen.',
|
||||
},
|
||||
status: {
|
||||
0: 'Aktiv',
|
||||
1: 'Inaktiv',
|
||||
2: 'Gesperrt',
|
||||
3: 'Gelöscht',
|
||||
4: 'Ausstehend',
|
||||
5: 'Löschung ausstehend',
|
||||
6: 'Gebannt',
|
||||
unknown: 'Unbekannt ({status})',
|
||||
},
|
||||
},
|
||||
es: {
|
||||
app: { title: 'Perfiles Encore' },
|
||||
nav: {
|
||||
menu: 'Menú',
|
||||
profiles: 'Perfiles',
|
||||
myProfile: 'Mi perfil',
|
||||
login: 'Iniciar sesión',
|
||||
register: 'Registrarse',
|
||||
logout: 'Cerrar sesión ({name})',
|
||||
logoutAction: 'Cerrar sesión',
|
||||
language: 'Idioma',
|
||||
},
|
||||
actions: {
|
||||
cancel: 'Cancelar',
|
||||
save: 'Guardar',
|
||||
create: 'Crear',
|
||||
remove: 'Eliminar',
|
||||
chooseFile: 'Elegir archivo',
|
||||
camera: 'Cámara',
|
||||
takePhoto: 'Tomar foto',
|
||||
crop: 'Recortar',
|
||||
newProfile: 'Nuevo perfil',
|
||||
register: 'Registrarse',
|
||||
editProfile: 'Editar perfil',
|
||||
updateRole: 'Actualizar rol',
|
||||
updateStatus: 'Actualizar estado',
|
||||
updatePassword: 'Actualizar contraseña',
|
||||
},
|
||||
fields: {
|
||||
email: 'Email',
|
||||
password: 'Contraseña',
|
||||
displayName: 'Nombre visible',
|
||||
firstName: 'Nombre',
|
||||
lastName: 'Apellido',
|
||||
address: 'Dirección',
|
||||
cap: 'Código postal',
|
||||
city: 'Ciudad',
|
||||
country: 'País',
|
||||
role: 'Rol',
|
||||
status: 'Estado',
|
||||
},
|
||||
avatar: {
|
||||
cropAvatar: 'Recortar avatar',
|
||||
cameraUnavailable: 'La cámara no está disponible en este navegador.',
|
||||
},
|
||||
admin: {
|
||||
dashboard: 'Panel',
|
||||
profiles: 'Perfiles',
|
||||
personalData: 'Datos personales',
|
||||
profile: 'Perfil',
|
||||
notArtist: 'No artista',
|
||||
artist: 'Artista',
|
||||
columns: {
|
||||
name: 'Nombre',
|
||||
email: 'Email',
|
||||
role: 'Rol',
|
||||
status: 'Estado',
|
||||
artist: 'Artista',
|
||||
created: 'Creado',
|
||||
updated: 'Actualizado',
|
||||
},
|
||||
notifications: {
|
||||
profileCreated: 'Perfil creado.',
|
||||
profileUpdated: 'Perfil actualizado.',
|
||||
personalDataUpdated: 'Datos personales actualizados.',
|
||||
roleUpdated: 'Rol actualizado.',
|
||||
statusUpdated: 'Estado actualizado.',
|
||||
passwordUpdated: 'Contraseña actualizada.',
|
||||
},
|
||||
},
|
||||
profile: {
|
||||
title: 'Mi perfil',
|
||||
avatar: 'Avatar',
|
||||
password: 'Contraseña',
|
||||
currentPassword: 'Contraseña actual',
|
||||
newPassword: 'Nueva contraseña',
|
||||
confirmNewPassword: 'Confirmar nueva contraseña',
|
||||
passwordMismatch: 'Las contraseñas no coinciden.',
|
||||
loginRequired: 'Debes iniciar sesión para ver tu perfil.',
|
||||
},
|
||||
login: {
|
||||
title: 'Iniciar sesión',
|
||||
needAccount: 'Crear una cuenta',
|
||||
forgotPassword: 'Contraseña olvidada',
|
||||
},
|
||||
passwordReset: {
|
||||
title: 'Recuperar contraseña',
|
||||
newPasswordTitle: 'Elige una nueva contraseña',
|
||||
sendLink: 'Enviar enlace',
|
||||
updatePassword: 'Actualizar contraseña',
|
||||
backToLogin: 'Volver al inicio de sesión',
|
||||
requestSent: 'Si el email existe, enviamos un enlace para restablecer la contraseña.',
|
||||
resetDone: 'Contraseña actualizada. Ahora puedes iniciar sesión.',
|
||||
},
|
||||
register: {
|
||||
title: 'Crear cuenta',
|
||||
welcomeTitle: 'Bienvenido',
|
||||
haveAccount: 'Ya tengo una cuenta',
|
||||
success: 'Registro completado. Ahora puedes iniciar sesión.',
|
||||
confirmationSent: 'Te hemos enviado una solicitud de confirmación. Revisa tu buzón de correo.',
|
||||
emailUnavailable: 'Este email ya está registrado.',
|
||||
emailDomainInvalid: 'Este dominio de email no puede recibir correo.',
|
||||
},
|
||||
welcome: {
|
||||
title: 'Confirmación de email',
|
||||
success: 'Email confirmado para {email}.',
|
||||
missingToken: 'Falta el token de confirmación.',
|
||||
},
|
||||
pages: {
|
||||
home: 'Inicio',
|
||||
goHome: 'Ir al inicio',
|
||||
goToIndex: 'Ir a la página inicial',
|
||||
goToSecond: 'Ir a la segunda página',
|
||||
cropperExample: 'Ejemplo de recorte',
|
||||
notFound: 'Vaya. Aquí no hay nada...',
|
||||
unauthorized: 'No autorizado',
|
||||
unauthorizedHint: 'Debes iniciar sesión para acceder a esta página.',
|
||||
},
|
||||
status: {
|
||||
0: 'Activo',
|
||||
1: 'Inactivo',
|
||||
2: 'Suspendido',
|
||||
3: 'Eliminado',
|
||||
4: 'Pendiente',
|
||||
5: 'Esperando eliminación',
|
||||
6: 'Bloqueado',
|
||||
unknown: 'Desconocido ({status})',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function detectLocale(): SupportedLocale {
|
||||
const stored = window.localStorage.getItem(storageKey);
|
||||
if (isSupportedLocale(stored)) return stored;
|
||||
|
||||
const browserLocale = navigator.language.split('-')[0] ?? null;
|
||||
if (isSupportedLocale(browserLocale)) return browserLocale;
|
||||
|
||||
return fallbackLocale;
|
||||
}
|
||||
|
||||
function isSupportedLocale(locale: string | null): locale is SupportedLocale {
|
||||
return localeOptions.some((option) => option.value === locale);
|
||||
}
|
||||
|
||||
export const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: detectLocale(),
|
||||
fallbackLocale,
|
||||
messages,
|
||||
});
|
||||
|
||||
export function setLocale(locale: SupportedLocale) {
|
||||
i18n.global.locale.value = locale;
|
||||
window.localStorage.setItem(storageKey, locale);
|
||||
}
|
||||
132
frontend/src/layouts/AdminLayout.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<q-layout view="lHh Lpr lFf">
|
||||
<q-header elevated class="bg-warning text-black">
|
||||
<q-toolbar>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
round
|
||||
icon="menu"
|
||||
:aria-label="t('nav.menu')"
|
||||
@click="toggleLeftDrawer"
|
||||
/>
|
||||
|
||||
<q-toolbar-title>
|
||||
{{ t('app.title') }}
|
||||
</q-toolbar-title>
|
||||
|
||||
<q-select
|
||||
v-model="selectedLocale"
|
||||
:options="localeOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
emit-value
|
||||
map-options
|
||||
dense
|
||||
borderless
|
||||
class="q-ml-md language-select"
|
||||
:aria-label="t('nav.language')"
|
||||
/>
|
||||
|
||||
</q-toolbar>
|
||||
</q-header>
|
||||
|
||||
<q-drawer
|
||||
v-model="leftDrawerOpen"
|
||||
show-if-above
|
||||
bordered
|
||||
>
|
||||
<q-list>
|
||||
<DrawerUserCard />
|
||||
<q-separator />
|
||||
<q-item clickable to="/">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="home" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>{{ t('pages.home') }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item clickable to="/admin/dashboard">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="dashboard" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>{{ t('admin.dashboard') }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item clickable to="/admin/profiles">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="people" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>{{ t('nav.profiles') }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-drawer>
|
||||
|
||||
<q-page-container>
|
||||
<router-view />
|
||||
</q-page-container>
|
||||
</q-layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import DrawerUserCard from '@/components/DrawerUserCard.vue';
|
||||
import { useProfilesStore } from '@/stores/profiles-store';
|
||||
import { localeOptions, setLocale, type SupportedLocale } from '@/i18n';
|
||||
|
||||
const profilesStore = useProfilesStore();
|
||||
const router = useRouter();
|
||||
const { locale, t } = useI18n();
|
||||
const selectedLocale = computed({
|
||||
get: () => locale.value as SupportedLocale,
|
||||
set: (value: SupportedLocale) => setLocale(value),
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
if (profilesStore.isAuthenticated && !profilesStore.profile) {
|
||||
await profilesStore.me();
|
||||
}
|
||||
} catch {
|
||||
profilesStore.logout();
|
||||
}
|
||||
|
||||
if (!profilesStore.isAuthenticated || profilesStore.profile?.role !== 'admin') {
|
||||
await router.replace('/401');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const leftDrawerOpen = ref(false);
|
||||
|
||||
function toggleLeftDrawer () {
|
||||
leftDrawerOpen.value = !leftDrawerOpen.value;
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
profilesStore.logout();
|
||||
await router.push('/');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.language-select {
|
||||
width: 90px;
|
||||
max-width: 90px;
|
||||
color: #000;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.language-select :deep(.q-field__native),
|
||||
.language-select :deep(.q-field__append),
|
||||
.language-select :deep(.q-icon) {
|
||||
color: #000;
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
126
frontend/src/layouts/MainLayout.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<q-layout view="lHh Lpr lFf">
|
||||
<q-header elevated>
|
||||
<q-toolbar>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
round
|
||||
icon="menu"
|
||||
:aria-label="t('nav.menu')"
|
||||
@click="toggleLeftDrawer"
|
||||
/>
|
||||
|
||||
<q-toolbar-title>
|
||||
{{ t('app.title') }}
|
||||
</q-toolbar-title>
|
||||
|
||||
<q-select
|
||||
v-model="selectedLocale"
|
||||
:options="localeOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
emit-value
|
||||
map-options
|
||||
dense
|
||||
borderless
|
||||
class="q-ml-md language-select"
|
||||
:aria-label="t('nav.language')"
|
||||
/>
|
||||
|
||||
|
||||
|
||||
|
||||
</q-toolbar>
|
||||
</q-header>
|
||||
|
||||
<q-drawer
|
||||
v-model="leftDrawerOpen"
|
||||
show-if-above
|
||||
bordered
|
||||
>
|
||||
<q-list>
|
||||
<DrawerUserCard />
|
||||
<q-separator />
|
||||
|
||||
<q-item v-if="isAdmin" clickable to="/admin/dashboard">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="dashboard" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>{{ t('admin.dashboard') }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item-label
|
||||
header
|
||||
>
|
||||
Essential Links
|
||||
</q-item-label>
|
||||
|
||||
|
||||
</q-list>
|
||||
</q-drawer>
|
||||
|
||||
<q-page-container>
|
||||
<router-view />
|
||||
</q-page-container>
|
||||
</q-layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import DrawerUserCard from '@/components/DrawerUserCard.vue';
|
||||
import { useProfilesStore } from '@/stores/profiles-store';
|
||||
import { localeOptions, setLocale, type SupportedLocale } from '@/i18n';
|
||||
|
||||
const profilesStore = useProfilesStore();
|
||||
const router = useRouter();
|
||||
const { locale, t } = useI18n();
|
||||
const selectedLocale = computed({
|
||||
get: () => locale.value as SupportedLocale,
|
||||
set: (value: SupportedLocale) => setLocale(value),
|
||||
});
|
||||
const isAdmin = computed(() => profilesStore.profile?.role === 'admin');
|
||||
|
||||
onMounted(async () => {
|
||||
if (profilesStore.isAuthenticated && !profilesStore.profile) {
|
||||
try {
|
||||
await profilesStore.me();
|
||||
} catch {
|
||||
profilesStore.logout();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
const leftDrawerOpen = ref(false);
|
||||
|
||||
function toggleLeftDrawer () {
|
||||
leftDrawerOpen.value = !leftDrawerOpen.value;
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
profilesStore.logout();
|
||||
await router.push('/');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.language-select {
|
||||
width: 90px;
|
||||
max-width: 90px;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.language-select :deep(.q-field__native),
|
||||
.language-select :deep(.q-field__append),
|
||||
.language-select :deep(.q-icon) {
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
23
frontend/src/pages/ErrorNotFound.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<div class="fullscreen bg-blue text-white text-center q-pa-md flex flex-center">
|
||||
<div>
|
||||
<div style="font-size: 30vh">
|
||||
404
|
||||
</div>
|
||||
|
||||
<div class="text-h2" style="opacity:.4">
|
||||
{{ $t('pages.notFound') }}
|
||||
</div>
|
||||
|
||||
<q-btn
|
||||
class="q-mt-xl"
|
||||
color="white"
|
||||
text-color="blue"
|
||||
unelevated
|
||||
to="/"
|
||||
:label="$t('pages.goHome')"
|
||||
no-caps
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
35
frontend/src/pages/ErrorUnauthorized.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<div class="fullscreen bg-deep-orange text-white text-center q-pa-md flex flex-center">
|
||||
<div>
|
||||
<div style="font-size: 30vh">
|
||||
401
|
||||
</div>
|
||||
|
||||
<div class="text-h2" style="opacity:.55">
|
||||
{{ $t('pages.unauthorized') }}
|
||||
</div>
|
||||
|
||||
<div class="text-subtitle1 q-mt-md" style="opacity:.8">
|
||||
{{ $t('pages.unauthorizedHint') }}
|
||||
</div>
|
||||
|
||||
<div class="row justify-center q-gutter-sm q-mt-xl">
|
||||
<q-btn
|
||||
color="white"
|
||||
text-color="deep-orange"
|
||||
unelevated
|
||||
to="/login"
|
||||
:label="$t('nav.login')"
|
||||
no-caps
|
||||
/>
|
||||
<q-btn
|
||||
flat
|
||||
color="white"
|
||||
to="/"
|
||||
:label="$t('pages.goHome')"
|
||||
no-caps
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
19
frontend/src/pages/IndexPage.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<q-page class="flex flex-center">
|
||||
<div class="column items-center">
|
||||
|
||||
<q-btn
|
||||
class="q-mt-md"
|
||||
color="primary"
|
||||
to="/second"
|
||||
:label="$t('pages.goToSecond')"
|
||||
no-caps
|
||||
/>
|
||||
|
||||
</div>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
97
frontend/src/pages/LoginPage.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<q-page class="flex flex-center">
|
||||
<q-card class="q-pa-md" style="width: 100%; max-width: 400px">
|
||||
<q-card-section>
|
||||
<div class="text-h6">{{ t('login.title') }}</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section>
|
||||
<q-form class="column q-gutter-md" @submit.prevent="onSubmit">
|
||||
<q-banner v-if="registered" class="bg-positive text-white" rounded>
|
||||
{{ t('register.success') }}
|
||||
</q-banner>
|
||||
|
||||
<q-input
|
||||
v-model="email"
|
||||
:label="t('fields.email')"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
outlined
|
||||
:rules="[(val) => !!val || t('fields.email')]"
|
||||
/>
|
||||
|
||||
<div class="column q-gutter-xs">
|
||||
<div class="text-right">
|
||||
<router-link to="/password-reset" class="text-primary text-caption text-no-decoration">
|
||||
{{ t('login.forgotPassword') }}
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<q-input
|
||||
v-model="password"
|
||||
:label="t('fields.password')"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
autocomplete="current-password"
|
||||
outlined
|
||||
:rules="[(val) => !!val || t('fields.password')]"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-icon
|
||||
:name="showPassword ? 'visibility_off' : 'visibility'"
|
||||
class="cursor-pointer"
|
||||
@click="showPassword = !showPassword"
|
||||
/>
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
|
||||
<q-banner v-if="store.error" class="bg-negative text-white" rounded>
|
||||
{{ store.error }}
|
||||
</q-banner>
|
||||
|
||||
<q-btn
|
||||
type="submit"
|
||||
color="primary"
|
||||
:label="t('nav.login')"
|
||||
:loading="store.loading"
|
||||
class="full-width"
|
||||
no-caps
|
||||
/>
|
||||
|
||||
<q-btn flat no-caps to="/register" :label="t('login.needAccount')" />
|
||||
</q-form>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useProfilesStore } from '@/stores/profiles-store';
|
||||
|
||||
const { t } = useI18n();
|
||||
const email = ref('');
|
||||
const password = ref('');
|
||||
const showPassword = ref(false);
|
||||
|
||||
const store = useProfilesStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const registered = route.query.registered === '1';
|
||||
|
||||
if (typeof route.query.email === 'string') {
|
||||
email.value = route.query.email;
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
try {
|
||||
await store.login({ user_email: email.value, password: password.value });
|
||||
await router.push('/');
|
||||
} catch {
|
||||
// store.error already holds the failure message
|
||||
}
|
||||
}
|
||||
</script>
|
||||
132
frontend/src/pages/PasswordResetPage.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<q-page class="flex flex-center">
|
||||
<q-card class="q-pa-md" style="width: 100%; max-width: 400px">
|
||||
<q-card-section>
|
||||
<div class="text-h6">{{ pageTitle }}</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section>
|
||||
<q-form v-if="!token" class="column q-gutter-md" @submit.prevent="requestReset">
|
||||
<q-banner v-if="requestSent" class="bg-positive text-white" rounded>
|
||||
{{ t('passwordReset.requestSent') }}
|
||||
</q-banner>
|
||||
|
||||
<q-input
|
||||
v-if="!requestSent"
|
||||
v-model="email"
|
||||
:label="t('fields.email')"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
outlined
|
||||
:rules="[(val) => !!val || t('fields.email')]"
|
||||
/>
|
||||
|
||||
<q-banner v-if="error" class="bg-negative text-white" rounded>
|
||||
{{ error }}
|
||||
</q-banner>
|
||||
|
||||
<q-btn
|
||||
v-if="!requestSent"
|
||||
type="submit"
|
||||
color="primary"
|
||||
:label="t('passwordReset.sendLink')"
|
||||
:loading="loading"
|
||||
class="full-width"
|
||||
no-caps
|
||||
/>
|
||||
|
||||
<q-btn flat no-caps to="/login" :label="t('passwordReset.backToLogin')" />
|
||||
</q-form>
|
||||
|
||||
<q-form v-else class="column q-gutter-md" @submit.prevent="confirmReset">
|
||||
<q-banner v-if="resetDone" class="bg-positive text-white" rounded>
|
||||
{{ t('passwordReset.resetDone') }}
|
||||
</q-banner>
|
||||
|
||||
<q-input
|
||||
v-if="!resetDone"
|
||||
v-model="password"
|
||||
:label="t('fields.password')"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
autocomplete="new-password"
|
||||
outlined
|
||||
:rules="[(val) => !!val || t('fields.password')]"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-icon
|
||||
:name="showPassword ? 'visibility_off' : 'visibility'"
|
||||
class="cursor-pointer"
|
||||
@click="showPassword = !showPassword"
|
||||
/>
|
||||
</template>
|
||||
</q-input>
|
||||
|
||||
<q-banner v-if="error" class="bg-negative text-white" rounded>
|
||||
{{ error }}
|
||||
</q-banner>
|
||||
|
||||
<q-btn
|
||||
v-if="!resetDone"
|
||||
type="submit"
|
||||
color="primary"
|
||||
:label="t('passwordReset.updatePassword')"
|
||||
:loading="loading"
|
||||
class="full-width"
|
||||
no-caps
|
||||
/>
|
||||
|
||||
<q-btn flat no-caps to="/login" :label="t('passwordReset.backToLogin')" />
|
||||
</q-form>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import Client, { Local } from '@/encore/client';
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const client = new Client(Local);
|
||||
|
||||
const token = computed(() => (typeof route.query.token === 'string' ? route.query.token : ''));
|
||||
const pageTitle = computed(() => (token.value ? t('passwordReset.newPasswordTitle') : t('passwordReset.title')));
|
||||
|
||||
const email = ref(typeof route.query.email === 'string' ? route.query.email : '');
|
||||
const password = ref('');
|
||||
const showPassword = ref(false);
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
const requestSent = ref(false);
|
||||
const resetDone = ref(false);
|
||||
|
||||
async function withLoading(fn: () => Promise<void>) {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function requestReset() {
|
||||
await withLoading(async () => {
|
||||
await client.registration.RequestPasswordReset({ email: email.value });
|
||||
requestSent.value = true;
|
||||
});
|
||||
}
|
||||
|
||||
async function confirmReset() {
|
||||
await withLoading(async () => {
|
||||
await client.registration.ConfirmPasswordReset(token.value, { password: password.value });
|
||||
resetDone.value = true;
|
||||
password.value = '';
|
||||
});
|
||||
}
|
||||
</script>
|
||||
482
frontend/src/pages/ProfilePage.vue
Normal file
@@ -0,0 +1,482 @@
|
||||
<template>
|
||||
<q-page class="q-pa-md">
|
||||
<div class="text-h5 q-mb-md">{{ t('profile.title') }}</div>
|
||||
|
||||
<q-banner v-if="profilesStore.error" class="bg-negative text-white q-mb-md" rounded>
|
||||
{{ profilesStore.error }}
|
||||
</q-banner>
|
||||
|
||||
<q-card v-if="profilesStore.profile" flat bordered style="max-width: 560px">
|
||||
<q-card-section class="row items-center q-gutter-sm">
|
||||
<q-avatar size="48px">
|
||||
<img v-if="profileForm.avatar_url" :src="profileForm.avatar_url" />
|
||||
<q-icon v-else name="person" />
|
||||
</q-avatar>
|
||||
<div>
|
||||
<div class="text-subtitle1">{{ profilesStore.profile.email }}</div>
|
||||
<div class="text-subtitle1">{{ displayValue(profileForm.display_name) }}</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-tabs v-model="profileTab" align="left" class="text-primary" dense>
|
||||
<q-tab v-if="isEditMode" name="avatar" :label="t('profile.avatar')" />
|
||||
<q-tab name="profile" :label="t('admin.profile')" />
|
||||
<q-tab name="personal" :label="t('admin.personalData')" />
|
||||
<q-tab v-if="isEditMode" name="password" :label="t('profile.password')" />
|
||||
</q-tabs>
|
||||
<q-separator />
|
||||
|
||||
<q-tab-panels v-model="profileTab" animated>
|
||||
<q-tab-panel v-if="isEditMode" name="avatar" class="q-gutter-md">
|
||||
<AvatarUpload v-model="profileForm.avatar_url" :uploader="profilesStore.uploadAvatar" />
|
||||
<div v-if="profileFormErrors.avatar_url" class="text-negative text-caption">
|
||||
{{ profileFormErrors.avatar_url }}
|
||||
</div>
|
||||
</q-tab-panel>
|
||||
|
||||
<q-tab-panel name="profile" class="q-gutter-md">
|
||||
<q-list separator>
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-item-label caption>{{ t('fields.displayName') }}</q-item-label>
|
||||
<q-item-label>{{ displayValue(profileForm.display_name) }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-item-label caption>{{ t('fields.email') }}</q-item-label>
|
||||
<q-item-label>{{ profilesStore.profile.email }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-item-label caption>{{ t('fields.role') }}</q-item-label>
|
||||
<q-item-label>{{ profilesStore.profile.role }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-item-label caption>{{ t('fields.status') }}</q-item-label>
|
||||
<q-item-label>
|
||||
<ProfileStatusBadge :status="profilesStore.profile.status" />
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-tab-panel>
|
||||
|
||||
<q-tab-panel name="personal" class="q-gutter-md">
|
||||
<template v-if="isEditMode">
|
||||
<q-input
|
||||
ref="firstNameInputRef"
|
||||
v-model="personalForm.first_name"
|
||||
:label="t('fields.firstName')"
|
||||
:error="Boolean(personalFormErrors.first_name)"
|
||||
:error-message="personalFormErrors.first_name"
|
||||
/>
|
||||
<q-input
|
||||
v-model="personalForm.last_name"
|
||||
:label="t('fields.lastName')"
|
||||
:error="Boolean(personalFormErrors.last_name)"
|
||||
:error-message="personalFormErrors.last_name"
|
||||
/>
|
||||
<q-input
|
||||
v-model="personalForm.address"
|
||||
:label="t('fields.address')"
|
||||
:error="Boolean(personalFormErrors.address)"
|
||||
:error-message="personalFormErrors.address"
|
||||
/>
|
||||
<q-input
|
||||
v-model="personalForm.cap"
|
||||
:label="t('fields.cap')"
|
||||
:error="Boolean(personalFormErrors.cap)"
|
||||
:error-message="personalFormErrors.cap"
|
||||
/>
|
||||
<q-input
|
||||
v-model="personalForm.city"
|
||||
:label="t('fields.city')"
|
||||
:error="Boolean(personalFormErrors.city)"
|
||||
:error-message="personalFormErrors.city"
|
||||
/>
|
||||
<q-select
|
||||
v-model="personalForm.country"
|
||||
:options="filteredCountries"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
emit-value
|
||||
map-options
|
||||
use-input
|
||||
clearable
|
||||
input-debounce="200"
|
||||
:label="t('fields.country')"
|
||||
:error="Boolean(personalFormErrors.country)"
|
||||
:error-message="personalFormErrors.country"
|
||||
@filter="filterCountries"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<q-list v-else separator>
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-item-label caption>{{ t('fields.firstName') }}</q-item-label>
|
||||
<q-item-label>{{ displayValue(personalForm.first_name) }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-item-label caption>{{ t('fields.lastName') }}</q-item-label>
|
||||
<q-item-label>{{ displayValue(personalForm.last_name) }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-item-label caption>{{ t('fields.address') }}</q-item-label>
|
||||
<q-item-label>{{ displayValue(personalForm.address) }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-item-label caption>{{ t('fields.cap') }}</q-item-label>
|
||||
<q-item-label>{{ displayValue(personalForm.cap) }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-item-label caption>{{ t('fields.city') }}</q-item-label>
|
||||
<q-item-label>{{ displayValue(personalForm.city) }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-item-label caption>{{ t('fields.country') }}</q-item-label>
|
||||
<q-item-label>{{ displayValue(countryLabel) }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-tab-panel>
|
||||
|
||||
<q-tab-panel v-if="isEditMode" name="password" class="q-gutter-md">
|
||||
<q-input
|
||||
ref="currentPasswordInputRef"
|
||||
v-model="passwordForm.current_password"
|
||||
:label="t('profile.currentPassword')"
|
||||
:type="showCurrentPassword ? 'text' : 'password'"
|
||||
autocomplete="current-password"
|
||||
:error="Boolean(passwordFormErrors.current_password)"
|
||||
:error-message="passwordFormErrors.current_password"
|
||||
outlined
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-icon
|
||||
:name="showCurrentPassword ? 'visibility_off' : 'visibility'"
|
||||
class="cursor-pointer"
|
||||
@click="showCurrentPassword = !showCurrentPassword"
|
||||
/>
|
||||
</template>
|
||||
</q-input>
|
||||
|
||||
<q-input
|
||||
v-model="passwordForm.password"
|
||||
:label="t('profile.newPassword')"
|
||||
:type="showNewPassword ? 'text' : 'password'"
|
||||
autocomplete="new-password"
|
||||
:error="Boolean(passwordFormErrors.password)"
|
||||
:error-message="passwordFormErrors.password"
|
||||
outlined
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-icon
|
||||
:name="showNewPassword ? 'visibility_off' : 'visibility'"
|
||||
class="cursor-pointer"
|
||||
@click="showNewPassword = !showNewPassword"
|
||||
/>
|
||||
</template>
|
||||
</q-input>
|
||||
|
||||
<q-input
|
||||
v-model="passwordConfirmation"
|
||||
:label="t('profile.confirmNewPassword')"
|
||||
:type="showPasswordConfirmation ? 'text' : 'password'"
|
||||
autocomplete="new-password"
|
||||
:error="Boolean(passwordConfirmationError)"
|
||||
:error-message="passwordConfirmationError"
|
||||
outlined
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-icon
|
||||
:name="showPasswordConfirmation ? 'visibility_off' : 'visibility'"
|
||||
class="cursor-pointer"
|
||||
@click="showPasswordConfirmation = !showPasswordConfirmation"
|
||||
/>
|
||||
</template>
|
||||
</q-input>
|
||||
</q-tab-panel>
|
||||
</q-tab-panels>
|
||||
|
||||
<q-card-actions v-if="isEditMode" align="right">
|
||||
<q-btn flat :label="t('actions.cancel')" @click="cancelEdit" />
|
||||
<q-btn color="primary" :label="t('actions.save')" :loading="profilesStore.loading" @click="save" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
|
||||
<div v-else-if="!profilesStore.isAuthenticated" class="text-grey">
|
||||
{{ t('profile.loginRequired') }}
|
||||
</div>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Notify } from 'quasar';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { countries } from '@/data/countries';
|
||||
import { AuthSetPasswordParamsSchema, ProfilesPersonalDataParamsSchema, ProfilesProfileParamsSchema } from '@/encore/zod';
|
||||
import {
|
||||
useProfilesStore,
|
||||
type PersonalDataParams,
|
||||
type Profile,
|
||||
type ProfileParams,
|
||||
type SetPasswordParams,
|
||||
} from '@/stores/profiles-store';
|
||||
import AvatarUpload from '@/components/AvatarUpload.vue';
|
||||
import ProfileStatusBadge from '@/components/ProfileStatusBadge.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const profilesStore = useProfilesStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
type CountryOption = { label: string; value: string };
|
||||
type ProfileTab = 'avatar' | 'profile' | 'personal' | 'password';
|
||||
type Focusable = { focus: () => void };
|
||||
|
||||
const countryOptions: CountryOption[] = countries.map((c) => {
|
||||
const [value, label] = Object.entries(c)[0] as [string, string];
|
||||
return { label, value };
|
||||
});
|
||||
|
||||
const filteredCountries = ref<CountryOption[]>(countryOptions);
|
||||
const profileTab = ref<ProfileTab>('profile');
|
||||
const isEditMode = computed(() => route.query.mode === 'edit');
|
||||
const countryLabel = computed(() => countryOptions.find((c) => c.value === personalForm.country)?.label ?? personalForm.country);
|
||||
const firstNameInputRef = ref<Focusable | null>(null);
|
||||
const currentPasswordInputRef = ref<Focusable | null>(null);
|
||||
|
||||
const profileForm = reactive<ProfileParams>({
|
||||
display_name: '',
|
||||
avatar_url: '',
|
||||
});
|
||||
const profileFormErrors = reactive<Partial<Record<keyof ProfileParams, string>>>({});
|
||||
|
||||
const personalForm = reactive<PersonalDataParams>({
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
address: '',
|
||||
cap: '',
|
||||
city: '',
|
||||
country: '',
|
||||
});
|
||||
const personalFormErrors = reactive<Partial<Record<keyof PersonalDataParams, string>>>({});
|
||||
|
||||
const passwordForm = reactive<SetPasswordParams>({
|
||||
current_password: '',
|
||||
password: '',
|
||||
});
|
||||
const passwordFormErrors = reactive<Partial<Record<keyof SetPasswordParams, string>>>({});
|
||||
const passwordConfirmation = ref('');
|
||||
const passwordConfirmationError = ref('');
|
||||
const showCurrentPassword = ref(false);
|
||||
const showNewPassword = ref(false);
|
||||
const showPasswordConfirmation = ref(false);
|
||||
|
||||
watch(
|
||||
() => profilesStore.profile,
|
||||
(profile) => {
|
||||
if (profile) {
|
||||
void loadProfile(profile);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(profileTab, () => {
|
||||
if (isEditMode.value) {
|
||||
void focusFirstField();
|
||||
}
|
||||
});
|
||||
|
||||
watch(isEditMode, (editing) => {
|
||||
if (editing) {
|
||||
profileTab.value = 'avatar';
|
||||
resetPasswordForm();
|
||||
void focusFirstField();
|
||||
} else if (profilesStore.profile) {
|
||||
profileTab.value = profileTab.value === 'avatar' || profileTab.value === 'password' ? 'profile' : profileTab.value;
|
||||
void loadProfile(profilesStore.profile);
|
||||
}
|
||||
});
|
||||
|
||||
function filterCountries(val: string, update: (cb: () => void) => void) {
|
||||
update(() => {
|
||||
const needle = val.toLowerCase();
|
||||
filteredCountries.value = needle
|
||||
? countryOptions.filter(
|
||||
(c) => c.label.toLowerCase().includes(needle) || c.value.toLowerCase().includes(needle),
|
||||
)
|
||||
: countryOptions;
|
||||
});
|
||||
}
|
||||
|
||||
async function loadProfile(profile: Profile) {
|
||||
profileForm.display_name = profile.display_name;
|
||||
profileForm.avatar_url = profile.avatar_url;
|
||||
clearProfileFormErrors();
|
||||
resetPersonalForm();
|
||||
|
||||
try {
|
||||
const data = profilesStore.personalData ?? (await profilesStore.fetchPersonalData());
|
||||
resetPersonalForm(data);
|
||||
} catch {
|
||||
// No personal data yet (404) - leave the form empty for creation.
|
||||
profilesStore.error = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
try {
|
||||
if (profileTab.value === 'avatar') {
|
||||
const parsed = validateProfileForm();
|
||||
if (!parsed) return;
|
||||
await profilesStore.update(parsed);
|
||||
} else if (profileTab.value === 'personal') {
|
||||
const parsed = validatePersonalForm();
|
||||
if (!parsed) return;
|
||||
await profilesStore.savePersonalData(parsed);
|
||||
Notify.create({ type: 'positive', message: t('admin.notifications.personalDataUpdated') });
|
||||
} else if (profileTab.value === 'password') {
|
||||
const parsed = validatePasswordForm();
|
||||
if (!parsed) return;
|
||||
await profilesStore.setPassword(parsed);
|
||||
resetPasswordForm();
|
||||
}
|
||||
await router.replace('/profile');
|
||||
} catch {
|
||||
// profilesStore.error already holds the failure message
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelEdit() {
|
||||
if (profilesStore.profile) {
|
||||
await loadProfile(profilesStore.profile);
|
||||
}
|
||||
await router.replace('/profile');
|
||||
}
|
||||
|
||||
function resetPersonalForm(data?: PersonalDataParams) {
|
||||
personalForm.first_name = data?.first_name ?? '';
|
||||
personalForm.last_name = data?.last_name ?? '';
|
||||
personalForm.address = data?.address ?? '';
|
||||
personalForm.cap = data?.cap ?? '';
|
||||
personalForm.city = data?.city ?? '';
|
||||
personalForm.country = data?.country ?? '';
|
||||
clearPersonalFormErrors();
|
||||
}
|
||||
|
||||
function validateProfileForm(): ProfileParams | null {
|
||||
clearProfileFormErrors();
|
||||
const result = ProfilesProfileParamsSchema.safeParse({ ...profileForm });
|
||||
if (result.success) return result.data;
|
||||
|
||||
const fieldErrors = result.error.flatten().fieldErrors;
|
||||
for (const key of Object.keys(fieldErrors) as (keyof ProfileParams)[]) {
|
||||
profileFormErrors[key] = fieldErrors[key]?.[0] ?? '';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function clearProfileFormErrors() {
|
||||
profileFormErrors.display_name = '';
|
||||
profileFormErrors.avatar_url = '';
|
||||
}
|
||||
|
||||
function validatePersonalForm(): PersonalDataParams | null {
|
||||
clearPersonalFormErrors();
|
||||
const result = ProfilesPersonalDataParamsSchema.safeParse({ ...personalForm });
|
||||
if (result.success) return result.data;
|
||||
|
||||
const fieldErrors = result.error.flatten().fieldErrors;
|
||||
for (const key of Object.keys(fieldErrors) as (keyof PersonalDataParams)[]) {
|
||||
personalFormErrors[key] = fieldErrors[key]?.[0] ?? '';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function clearPersonalFormErrors() {
|
||||
personalFormErrors.first_name = '';
|
||||
personalFormErrors.last_name = '';
|
||||
personalFormErrors.address = '';
|
||||
personalFormErrors.cap = '';
|
||||
personalFormErrors.city = '';
|
||||
personalFormErrors.country = '';
|
||||
}
|
||||
|
||||
function validatePasswordForm(): SetPasswordParams | null {
|
||||
clearPasswordFormErrors();
|
||||
const result = AuthSetPasswordParamsSchema.safeParse({ ...passwordForm });
|
||||
if (!result.success) {
|
||||
const fieldErrors = result.error.flatten().fieldErrors;
|
||||
for (const key of Object.keys(fieldErrors) as (keyof SetPasswordParams)[]) {
|
||||
passwordFormErrors[key] = fieldErrors[key]?.[0] ?? '';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (passwordForm.password !== passwordConfirmation.value) {
|
||||
passwordConfirmationError.value = t('profile.passwordMismatch');
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
function clearPasswordFormErrors() {
|
||||
passwordFormErrors.current_password = '';
|
||||
passwordFormErrors.password = '';
|
||||
passwordConfirmationError.value = '';
|
||||
}
|
||||
|
||||
function resetPasswordForm() {
|
||||
passwordForm.current_password = '';
|
||||
passwordForm.password = '';
|
||||
passwordConfirmation.value = '';
|
||||
showCurrentPassword.value = false;
|
||||
showNewPassword.value = false;
|
||||
showPasswordConfirmation.value = false;
|
||||
clearPasswordFormErrors();
|
||||
}
|
||||
|
||||
function displayValue(value: string | number | null | undefined) {
|
||||
const normalized = String(value ?? '').trim();
|
||||
return normalized || '-';
|
||||
}
|
||||
|
||||
async function focusFirstField() {
|
||||
await nextTick();
|
||||
if (profileTab.value === 'personal') {
|
||||
firstNameInputRef.value?.focus();
|
||||
} else if (profileTab.value === 'password') {
|
||||
currentPasswordInputRef.value?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (profilesStore.isAuthenticated && !profilesStore.profile) {
|
||||
try {
|
||||
await profilesStore.me();
|
||||
} catch {
|
||||
// profilesStore.error already holds the failure message
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
170
frontend/src/pages/RegisterPage.vue
Normal file
@@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<q-page class="flex flex-center">
|
||||
<q-card class="q-pa-md" style="width: 100%; max-width: 440px">
|
||||
<template v-if="success">
|
||||
<q-card-section>
|
||||
<div class="text-h6">{{ t('register.welcomeTitle') }}</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section>
|
||||
<q-banner class="bg-positive text-white" rounded>
|
||||
{{ t('register.confirmationSent') }}
|
||||
</q-banner>
|
||||
</q-card-section>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<q-card-section>
|
||||
<div class="text-h6">{{ t('register.title') }}</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section>
|
||||
<q-form class="column q-gutter-md" @submit.prevent="onSubmit">
|
||||
<q-input
|
||||
ref="emailInputRef"
|
||||
v-model="form.email"
|
||||
:label="t('fields.email')"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
:error="Boolean(fieldErrors.email)"
|
||||
:error-message="fieldErrors.email"
|
||||
:loading="checkingEmail"
|
||||
@blur="checkEmailAvailability"
|
||||
/>
|
||||
|
||||
<q-input
|
||||
v-model="form.password"
|
||||
:label="t('fields.password')"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
:error="Boolean(fieldErrors.password)"
|
||||
:error-message="fieldErrors.password"
|
||||
/>
|
||||
|
||||
<q-input
|
||||
v-model="form.display_name"
|
||||
:label="t('fields.displayName')"
|
||||
:error="Boolean(fieldErrors.display_name)"
|
||||
:error-message="fieldErrors.display_name"
|
||||
/>
|
||||
|
||||
<q-banner v-if="error" class="bg-negative text-white" rounded>
|
||||
{{ error }}
|
||||
</q-banner>
|
||||
|
||||
<q-btn
|
||||
type="submit"
|
||||
color="primary"
|
||||
:label="t('actions.register')"
|
||||
:loading="loading || checkingEmail"
|
||||
class="full-width"
|
||||
no-caps
|
||||
/>
|
||||
|
||||
<q-btn flat no-caps to="/login" :label="t('register.haveAccount')" />
|
||||
</q-form>
|
||||
</q-card-section>
|
||||
</template>
|
||||
</q-card>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onMounted, reactive, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Client, { Local, type registration } from '@/encore/client';
|
||||
import { RegistrationRegisterParamsSchema } from '@/encore/zod';
|
||||
|
||||
type Focusable = { focus: () => void };
|
||||
|
||||
const { t } = useI18n();
|
||||
const client = new Client(Local);
|
||||
const emailInputRef = ref<Focusable | null>(null);
|
||||
const loading = ref(false);
|
||||
const checkingEmail = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const success = ref(false);
|
||||
const fieldErrors = reactive<Partial<Record<keyof registration.RegisterParams, string>>>({});
|
||||
const form = reactive<registration.RegisterParams>({
|
||||
email: '',
|
||||
password: '',
|
||||
display_name: '',
|
||||
avatar_url: '',
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
emailInputRef.value?.focus();
|
||||
});
|
||||
|
||||
async function onSubmit() {
|
||||
const params = validateForm();
|
||||
if (!params) return;
|
||||
const emailAvailable = await checkEmailAvailability();
|
||||
if (!emailAvailable) return;
|
||||
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
success.value = false;
|
||||
try {
|
||||
await client.registration.Register(params);
|
||||
success.value = true;
|
||||
resetForm();
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkEmailAvailability(): Promise<boolean> {
|
||||
const emailResult = RegistrationRegisterParamsSchema.shape.email.safeParse(form.email);
|
||||
if (!emailResult.success) return false;
|
||||
|
||||
checkingEmail.value = true;
|
||||
fieldErrors.email = '';
|
||||
try {
|
||||
const res = await client.registration.CheckEmail(form.email);
|
||||
if (!res.mx_valid) {
|
||||
fieldErrors.email = t('register.emailDomainInvalid');
|
||||
return false;
|
||||
}
|
||||
if (!res.available) {
|
||||
fieldErrors.email = t('register.emailUnavailable');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
fieldErrors.email = err instanceof Error ? err.message : String(err);
|
||||
return false;
|
||||
} finally {
|
||||
checkingEmail.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function validateForm(): registration.RegisterParams | null {
|
||||
clearFieldErrors();
|
||||
const result = RegistrationRegisterParamsSchema.safeParse({ ...form });
|
||||
if (result.success) return result.data;
|
||||
|
||||
const flattened = result.error.flatten().fieldErrors;
|
||||
for (const key of Object.keys(flattened) as (keyof registration.RegisterParams)[]) {
|
||||
fieldErrors[key] = flattened[key]?.[0] ?? '';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function clearFieldErrors() {
|
||||
fieldErrors.email = '';
|
||||
fieldErrors.password = '';
|
||||
fieldErrors.display_name = '';
|
||||
fieldErrors.avatar_url = '';
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.email = '';
|
||||
form.password = '';
|
||||
form.display_name = '';
|
||||
form.avatar_url = '';
|
||||
}
|
||||
</script>
|
||||
9
frontend/src/pages/SecondPage.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<q-page class="flex flex-center">
|
||||
<q-btn color="secondary" to="/" :label="$t('pages.goToIndex')" no-caps />
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
//
|
||||
</script>
|
||||
60
frontend/src/pages/WelcomePage.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<q-page class="flex flex-center">
|
||||
<q-card class="q-pa-md" style="width: 100%; max-width: 440px">
|
||||
<q-card-section>
|
||||
<div class="text-h6">{{ t('welcome.title') }}</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section>
|
||||
<q-inner-loading :showing="loading" />
|
||||
|
||||
<q-banner v-if="success" class="bg-positive text-white" rounded>
|
||||
{{ t('welcome.success', { email }) }}
|
||||
</q-banner>
|
||||
|
||||
<q-banner v-else-if="error" class="bg-negative text-white" rounded>
|
||||
{{ error }}
|
||||
</q-banner>
|
||||
|
||||
<q-banner v-else-if="!loading" class="bg-warning text-white" rounded>
|
||||
{{ t('welcome.missingToken') }}
|
||||
</q-banner>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="right">
|
||||
<q-btn color="primary" to="/login" :label="t('nav.login')" no-caps />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import Client, { Local } from '@/encore/client';
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const client = new Client(Local);
|
||||
const loading = ref(false);
|
||||
const success = ref(false);
|
||||
const error = ref('');
|
||||
const email = ref('');
|
||||
|
||||
onMounted(async () => {
|
||||
const token = typeof route.query.token === 'string' ? route.query.token : '';
|
||||
if (!token) return;
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await client.registration.ConfirmWelcome(token);
|
||||
success.value = res.confirmed;
|
||||
email.value = res.email;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
11
frontend/src/pages/admin/DashboardPage.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<q-page class="q-pa-md">
|
||||
<div class="text-h5">{{ t('admin.dashboard') }}</div>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
237
frontend/src/pages/admin/ProfilesPage.vue
Normal file
@@ -0,0 +1,237 @@
|
||||
<template>
|
||||
<q-page class="q-pa-md" ref="profilesPage">
|
||||
<div class="row items-center q-mb-md">
|
||||
<div class="text-h5">{{ t('admin.profiles') }}</div>
|
||||
<q-space />
|
||||
<q-btn color="primary" icon="add" :label="t('actions.newProfile')" @click="openCreate" />
|
||||
</div>
|
||||
|
||||
<q-banner v-if="adminStore.error" class="bg-negative text-white q-mb-md" rounded>
|
||||
{{ adminStore.error }}
|
||||
</q-banner>
|
||||
|
||||
<q-table
|
||||
:style="{ height: `${profilesTableHeight}px` }"
|
||||
class="sticky-header-table"
|
||||
:rows="adminStore.profiles"
|
||||
:columns="columns"
|
||||
row-key="user_id"
|
||||
:loading="adminStore.loading"
|
||||
flat
|
||||
bordered
|
||||
ref="profilesTable"
|
||||
>
|
||||
<template v-slot:body-cell-status="props">
|
||||
<q-td :props="props">
|
||||
<q-badge :color="statusInfo(props.value).color">{{ statusLabel(props.value) }}</q-badge>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<template v-slot:body-cell-is_artist="props">
|
||||
<q-td :props="props">
|
||||
<q-icon
|
||||
:name="props.value ? 'check_circle' : 'cancel'"
|
||||
:color="props.value ? 'positive' : 'grey-5'"
|
||||
size="sm"
|
||||
>
|
||||
<q-tooltip>{{ props.value ? t('admin.artist') : t('admin.notArtist') }}</q-tooltip>
|
||||
</q-icon>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<template v-slot:body-cell-avatar_url="props">
|
||||
<q-td :props="props" auto-width>
|
||||
<q-avatar size="32px">
|
||||
<img v-if="props.value" :src="props.value" />
|
||||
<q-icon v-else name="person" />
|
||||
</q-avatar>
|
||||
|
||||
<q-btn flat round dense icon="more_vert" size="sm" class="q-ml-xs">
|
||||
<q-menu>
|
||||
<q-list>
|
||||
<q-item v-close-popup clickable @click="openEdit(props.row)">
|
||||
<q-item-section>{{ t('actions.editProfile') }}</q-item-section>
|
||||
</q-item>
|
||||
<q-item v-close-popup clickable @click="openRoleEdit(props.row)">
|
||||
<q-item-section>{{ t('actions.updateRole') }}</q-item-section>
|
||||
</q-item>
|
||||
<q-item v-close-popup clickable @click="openStatusEdit(props.row)">
|
||||
<q-item-section>{{ t('actions.updateStatus') }}</q-item-section>
|
||||
</q-item>
|
||||
<q-item v-close-popup clickable @click="openPasswordEdit(props.row)">
|
||||
<q-item-section>{{ t('actions.updatePassword') }}</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
</template>
|
||||
</q-table>
|
||||
|
||||
<EditProfileDialog v-model="editDialogOpen" :profile="editingProfile" />
|
||||
<UpdateRoleDialog v-model="roleDialogOpen" :profile="roleEditingProfile" />
|
||||
<UpdateStatusDialog v-model="statusDialogOpen" :profile="statusEditingProfile" />
|
||||
<UpdatePasswordDialog v-model="passwordDialogOpen" :profile="passwordEditingProfile" />
|
||||
<CreateProfileDialog v-model="createDialogOpen" />
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.sticky-header-table { /* height or max-height is important */
|
||||
|
||||
.q-table__top,
|
||||
.q-table__bottom,
|
||||
thead tr:first-child th { /* bg color is important for th; just specify one */
|
||||
background-color: #ffffff;
|
||||
}
|
||||
thead tr th {
|
||||
position: sticky;
|
||||
z-index: 1;
|
||||
}
|
||||
thead tr:first-child th {
|
||||
top: 0;
|
||||
}
|
||||
/* this is when the loading indicator appears */
|
||||
&.q-table--loading thead tr:last-child th { /* height of all previous header rows */
|
||||
top: 48px;
|
||||
}
|
||||
/* prevent scrolling behind sticky top row on focus */
|
||||
tbody { /* height of all previous header rows */
|
||||
scroll-margin-top: 48px;
|
||||
}
|
||||
}</style>
|
||||
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch, type ComponentPublicInstance } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { QTableColumn } from 'quasar';
|
||||
import { useAdminStore, type Profile } from '@/stores/admin-store';
|
||||
import { useLayoutStore } from '@/stores/layout-store';
|
||||
import { statusInfo } from '@/stores/profiles-store';
|
||||
import CreateProfileDialog from './dialogs/CreateProfileDialog.vue';
|
||||
import EditProfileDialog from './dialogs/EditProfileDialog.vue';
|
||||
import UpdatePasswordDialog from './dialogs/UpdatePasswordDialog.vue';
|
||||
import UpdateRoleDialog from './dialogs/UpdateRoleDialog.vue';
|
||||
import UpdateStatusDialog from './dialogs/UpdateStatusDialog.vue';
|
||||
|
||||
const adminStore = useAdminStore();
|
||||
const layoutStore = useLayoutStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const profilesPage = ref<ComponentPublicInstance | null>(null);
|
||||
const profilesTable = ref<ComponentPublicInstance | null>(null);
|
||||
const profilesTableHeight = ref(610);
|
||||
|
||||
const editDialogOpen = ref(false);
|
||||
const editingProfile = ref<Profile | null>(null);
|
||||
|
||||
function openEdit(profile: Profile) {
|
||||
editingProfile.value = profile;
|
||||
editDialogOpen.value = true;
|
||||
}
|
||||
|
||||
const createDialogOpen = ref(false);
|
||||
|
||||
function openCreate() {
|
||||
createDialogOpen.value = true;
|
||||
}
|
||||
|
||||
const roleDialogOpen = ref(false);
|
||||
const roleEditingProfile = ref<Profile | null>(null);
|
||||
|
||||
function openRoleEdit(profile: Profile) {
|
||||
roleEditingProfile.value = profile;
|
||||
roleDialogOpen.value = true;
|
||||
}
|
||||
|
||||
const statusDialogOpen = ref(false);
|
||||
const statusEditingProfile = ref<Profile | null>(null);
|
||||
|
||||
function openStatusEdit(profile: Profile) {
|
||||
statusEditingProfile.value = profile;
|
||||
statusDialogOpen.value = true;
|
||||
}
|
||||
|
||||
const passwordDialogOpen = ref(false);
|
||||
const passwordEditingProfile = ref<Profile | null>(null);
|
||||
|
||||
function openPasswordEdit(profile: Profile) {
|
||||
passwordEditingProfile.value = profile;
|
||||
passwordDialogOpen.value = true;
|
||||
}
|
||||
|
||||
const columns = computed<QTableColumn[]>(() => [
|
||||
{ name: 'avatar_url', label: '', field: 'avatar_url', align: 'left' },
|
||||
{ name: 'display_name', label: t('admin.columns.name'), field: 'display_name', align: 'left', sortable: true },
|
||||
{ name: 'email', label: t('admin.columns.email'), field: 'email', align: 'left', sortable: true },
|
||||
{ name: 'role', label: t('admin.columns.role'), field: 'role', align: 'left', sortable: true },
|
||||
{ name: 'status', label: t('admin.columns.status'), field: 'status', align: 'left', sortable: true },
|
||||
{ name: 'is_artist', label: t('admin.columns.artist'), field: 'is_artist', align: 'center', sortable: true },
|
||||
{
|
||||
name: 'created_at',
|
||||
label: t('admin.columns.created'),
|
||||
field: 'created_at',
|
||||
align: 'left',
|
||||
sortable: true,
|
||||
format: (val: string) => new Date(val).toLocaleString(),
|
||||
},
|
||||
{
|
||||
name: 'updated_at',
|
||||
label: t('admin.columns.updated'),
|
||||
field: 'updated_at',
|
||||
align: 'left',
|
||||
sortable: true,
|
||||
format: (val: string) => new Date(val).toLocaleString(),
|
||||
},
|
||||
]);
|
||||
|
||||
function statusLabel(status: number) {
|
||||
const key = `status.${status}`;
|
||||
const translated = t(key);
|
||||
return translated === key ? t('status.unknown', { status }) : translated;
|
||||
}
|
||||
|
||||
function getElement(component: ComponentPublicInstance | null) {
|
||||
return component?.$el instanceof HTMLElement ? component.$el : null;
|
||||
}
|
||||
|
||||
function updateProfilesTableHeight() {
|
||||
const page = getElement(profilesPage.value);
|
||||
const table = getElement(profilesTable.value);
|
||||
|
||||
if (!page || !table) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tableRect = table.getBoundingClientRect();
|
||||
const pageStyle = window.getComputedStyle(page);
|
||||
const pagePaddingBottom = parseFloat(pageStyle.paddingBottom) || 0;
|
||||
const availableHeight = window.innerHeight - tableRect.top - pagePaddingBottom;
|
||||
|
||||
profilesTableHeight.value = Math.max(0, Math.floor(availableHeight));
|
||||
}
|
||||
|
||||
async function updateProfilesTableHeightAfterRender() {
|
||||
await nextTick();
|
||||
updateProfilesTableHeight();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
void adminStore.listProfiles();
|
||||
void adminStore.fetchSystemOptions();
|
||||
layoutStore.startBodyResizeListener();
|
||||
await updateProfilesTableHeightAfterRender();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [adminStore.error, layoutStore.bodyWidth, layoutStore.bodyHeight],
|
||||
() => {
|
||||
void updateProfilesTableHeightAfterRender();
|
||||
},
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
layoutStore.stopBodyResizeListener();
|
||||
});
|
||||
</script>
|
||||
88
frontend/src/pages/admin/dialogs/CreateProfileDialog.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<q-dialog v-model="open" @show="focusFirstField">
|
||||
<q-card style="min-width: 350px">
|
||||
<q-card-section>
|
||||
<div class="text-h6">{{ t('actions.newProfile') }}</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section class="q-gutter-md">
|
||||
<q-input
|
||||
ref="emailInputRef"
|
||||
v-model="createForm.email"
|
||||
:label="t('fields.email')"
|
||||
type="email"
|
||||
autocomplete="off"
|
||||
name="new-profile-email"
|
||||
/>
|
||||
<q-input
|
||||
v-model="createForm.password"
|
||||
:label="t('fields.password')"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
name="new-profile-password"
|
||||
/>
|
||||
<q-input v-model="createForm.display_name" :label="t('fields.displayName')" />
|
||||
<AvatarUpload v-model="createForm.avatar_url" :uploader="adminStore.uploadAvatar" />
|
||||
|
||||
<q-banner v-if="adminStore.error" class="bg-negative text-white" rounded>
|
||||
{{ adminStore.error }}
|
||||
</q-banner>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="right">
|
||||
<q-btn v-close-popup flat :label="t('actions.cancel')" />
|
||||
<q-btn color="primary" :label="t('actions.create')" :loading="adminStore.loading" @click="save" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Notify } from 'quasar';
|
||||
import { useAdminStore, type RegisterParams } from '@/stores/admin-store';
|
||||
import AvatarUpload from '@/components/AvatarUpload.vue';
|
||||
|
||||
type Focusable = { focus: () => void };
|
||||
|
||||
const open = defineModel<boolean>({ required: true });
|
||||
const adminStore = useAdminStore();
|
||||
const { t } = useI18n();
|
||||
const emailInputRef = ref<Focusable | null>(null);
|
||||
|
||||
const createForm = reactive<RegisterParams>({
|
||||
email: '',
|
||||
password: '',
|
||||
display_name: '',
|
||||
avatar_url: '',
|
||||
});
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
resetForm();
|
||||
}
|
||||
});
|
||||
|
||||
async function save() {
|
||||
try {
|
||||
await adminStore.insertProfile({ ...createForm });
|
||||
Notify.create({ type: 'positive', message: t('admin.notifications.profileCreated') });
|
||||
open.value = false;
|
||||
} catch {
|
||||
// adminStore.error already holds the failure message
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
createForm.email = '';
|
||||
createForm.password = '';
|
||||
createForm.display_name = '';
|
||||
createForm.avatar_url = '';
|
||||
}
|
||||
|
||||
async function focusFirstField() {
|
||||
await nextTick();
|
||||
emailInputRef.value?.focus();
|
||||
}
|
||||
</script>
|
||||
261
frontend/src/pages/admin/dialogs/EditProfileDialog.vue
Normal file
@@ -0,0 +1,261 @@
|
||||
<template>
|
||||
<q-dialog v-model="open" @show="focusFirstField">
|
||||
<q-card style="min-width: 350px">
|
||||
<q-card-section v-if="profile" class="row items-center q-gutter-sm">
|
||||
<q-avatar size="48px">
|
||||
<img v-if="editForm.avatar_url" :src="editForm.avatar_url" />
|
||||
<q-icon v-else name="person" />
|
||||
</q-avatar>
|
||||
<div>
|
||||
<div class="text-subtitle1">{{ profile.email }}</div>
|
||||
<div class="text-caption text-grey">{{ profile.role }} · {{ profile.user_id }}</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-tabs v-model="editTab" align="left" class="text-primary" dense>
|
||||
<q-tab name="profile" :label="t('admin.profile')" />
|
||||
<q-tab name="personal" :label="t('admin.personalData')" />
|
||||
</q-tabs>
|
||||
<q-separator />
|
||||
|
||||
<q-tab-panels v-model="editTab" animated>
|
||||
<q-tab-panel name="profile" class="q-gutter-md">
|
||||
<AvatarUpload v-model="editForm.avatar_url" :uploader="adminStore.uploadAvatar" />
|
||||
<div v-if="editFormErrors.avatar_url" class="text-negative text-caption">
|
||||
{{ editFormErrors.avatar_url }}
|
||||
</div>
|
||||
<q-input
|
||||
ref="displayNameInputRef"
|
||||
v-model="editForm.display_name"
|
||||
:label="t('fields.displayName')"
|
||||
:error="Boolean(editFormErrors.display_name)"
|
||||
:error-message="editFormErrors.display_name"
|
||||
/>
|
||||
</q-tab-panel>
|
||||
|
||||
<q-tab-panel name="personal" class="q-gutter-md">
|
||||
<q-input
|
||||
ref="firstNameInputRef"
|
||||
v-model="personalForm.first_name"
|
||||
:label="t('fields.firstName')"
|
||||
:error="Boolean(personalFormErrors.first_name)"
|
||||
:error-message="personalFormErrors.first_name"
|
||||
/>
|
||||
<q-input
|
||||
v-model="personalForm.last_name"
|
||||
:label="t('fields.lastName')"
|
||||
:error="Boolean(personalFormErrors.last_name)"
|
||||
:error-message="personalFormErrors.last_name"
|
||||
/>
|
||||
<q-input
|
||||
v-model="personalForm.address"
|
||||
:label="t('fields.address')"
|
||||
:error="Boolean(personalFormErrors.address)"
|
||||
:error-message="personalFormErrors.address"
|
||||
/>
|
||||
<q-input
|
||||
v-model="personalForm.cap"
|
||||
:label="t('fields.cap')"
|
||||
:error="Boolean(personalFormErrors.cap)"
|
||||
:error-message="personalFormErrors.cap"
|
||||
/>
|
||||
<q-input
|
||||
v-model="personalForm.city"
|
||||
:label="t('fields.city')"
|
||||
:error="Boolean(personalFormErrors.city)"
|
||||
:error-message="personalFormErrors.city"
|
||||
/>
|
||||
<q-select
|
||||
v-model="personalForm.country"
|
||||
:options="filteredCountries"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
emit-value
|
||||
map-options
|
||||
use-input
|
||||
clearable
|
||||
input-debounce="200"
|
||||
:label="t('fields.country')"
|
||||
:error="Boolean(personalFormErrors.country)"
|
||||
:error-message="personalFormErrors.country"
|
||||
@filter="filterCountries"
|
||||
/>
|
||||
</q-tab-panel>
|
||||
</q-tab-panels>
|
||||
|
||||
<q-card-section v-if="adminStore.error">
|
||||
<q-banner class="bg-negative text-white" rounded>{{ adminStore.error }}</q-banner>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="right">
|
||||
<q-btn v-close-popup flat :label="t('actions.cancel')" />
|
||||
<q-btn color="primary" :label="t('actions.save')" :loading="adminStore.loading" @click="save" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Notify } from 'quasar';
|
||||
import { useAdminStore, type PersonalDataParams, type Profile, type ProfileParams } from '@/stores/admin-store';
|
||||
import { countries } from '@/data/countries';
|
||||
import { AdminPersonalDataParamsSchema, AdminProfileParamsSchema } from '@/encore/zod';
|
||||
import AvatarUpload from '@/components/AvatarUpload.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
profile: Profile | null;
|
||||
}>();
|
||||
|
||||
const open = defineModel<boolean>({ required: true });
|
||||
const adminStore = useAdminStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
type CountryOption = { label: string; value: string };
|
||||
type EditTab = 'profile' | 'personal';
|
||||
type Focusable = { focus: () => void };
|
||||
|
||||
const countryOptions: CountryOption[] = countries.map((c) => {
|
||||
const [value, label] = Object.entries(c)[0] as [string, string];
|
||||
return { label, value };
|
||||
});
|
||||
|
||||
const filteredCountries = ref<CountryOption[]>(countryOptions);
|
||||
const editTab = ref<EditTab>('profile');
|
||||
const displayNameInputRef = ref<Focusable | null>(null);
|
||||
const firstNameInputRef = ref<Focusable | null>(null);
|
||||
const editForm = reactive<ProfileParams>({
|
||||
display_name: '',
|
||||
avatar_url: '',
|
||||
});
|
||||
const editFormErrors = reactive<Partial<Record<keyof ProfileParams, string>>>({});
|
||||
const personalForm = reactive<PersonalDataParams>({
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
address: '',
|
||||
cap: '',
|
||||
city: '',
|
||||
country: '',
|
||||
});
|
||||
const personalFormErrors = reactive<Partial<Record<keyof PersonalDataParams, string>>>({});
|
||||
|
||||
watch(
|
||||
() => [open.value, props.profile] as const,
|
||||
([isOpen, profile]) => {
|
||||
if (isOpen && profile) {
|
||||
void loadProfile(profile);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(editTab, () => {
|
||||
if (open.value) {
|
||||
void focusFirstField();
|
||||
}
|
||||
});
|
||||
|
||||
function filterCountries(val: string, update: (cb: () => void) => void) {
|
||||
update(() => {
|
||||
const needle = val.toLowerCase();
|
||||
filteredCountries.value = needle
|
||||
? countryOptions.filter(
|
||||
(c) => c.label.toLowerCase().includes(needle) || c.value.toLowerCase().includes(needle),
|
||||
)
|
||||
: countryOptions;
|
||||
});
|
||||
}
|
||||
|
||||
async function loadProfile(profile: Profile) {
|
||||
editTab.value = 'profile';
|
||||
editForm.display_name = profile.display_name;
|
||||
editForm.avatar_url = profile.avatar_url;
|
||||
clearEditFormErrors();
|
||||
resetPersonalForm();
|
||||
try {
|
||||
const data = await adminStore.getPersonalData(profile.user_id);
|
||||
resetPersonalForm(data);
|
||||
} catch {
|
||||
// No personal data yet (404) — leave the form empty for creation.
|
||||
adminStore.error = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!props.profile) return;
|
||||
try {
|
||||
if (editTab.value === 'profile') {
|
||||
const parsed = validateEditForm();
|
||||
if (!parsed) return;
|
||||
await adminStore.updateProfile(props.profile.user_id, parsed);
|
||||
Notify.create({ type: 'positive', message: t('admin.notifications.profileUpdated') });
|
||||
} else {
|
||||
const parsed = validatePersonalForm();
|
||||
if (!parsed) return;
|
||||
await adminStore.upsertPersonalData(props.profile.user_id, parsed);
|
||||
Notify.create({ type: 'positive', message: t('admin.notifications.personalDataUpdated') });
|
||||
}
|
||||
open.value = false;
|
||||
} catch {
|
||||
// adminStore.error already holds the failure message
|
||||
}
|
||||
}
|
||||
|
||||
function resetPersonalForm(data?: PersonalDataParams) {
|
||||
personalForm.first_name = data?.first_name ?? '';
|
||||
personalForm.last_name = data?.last_name ?? '';
|
||||
personalForm.address = data?.address ?? '';
|
||||
personalForm.cap = data?.cap ?? '';
|
||||
personalForm.city = data?.city ?? '';
|
||||
personalForm.country = data?.country ?? '';
|
||||
clearPersonalFormErrors();
|
||||
}
|
||||
|
||||
function validateEditForm(): ProfileParams | null {
|
||||
clearEditFormErrors();
|
||||
const result = AdminProfileParamsSchema.safeParse({ ...editForm });
|
||||
if (result.success) return result.data;
|
||||
|
||||
const fieldErrors = result.error.flatten().fieldErrors;
|
||||
for (const key of Object.keys(fieldErrors) as (keyof ProfileParams)[]) {
|
||||
editFormErrors[key] = fieldErrors[key]?.[0] ?? '';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function clearEditFormErrors() {
|
||||
editFormErrors.display_name = '';
|
||||
editFormErrors.avatar_url = '';
|
||||
}
|
||||
|
||||
function validatePersonalForm(): PersonalDataParams | null {
|
||||
clearPersonalFormErrors();
|
||||
const result = AdminPersonalDataParamsSchema.safeParse({ ...personalForm });
|
||||
if (result.success) return result.data;
|
||||
|
||||
const fieldErrors = result.error.flatten().fieldErrors;
|
||||
for (const key of Object.keys(fieldErrors) as (keyof PersonalDataParams)[]) {
|
||||
personalFormErrors[key] = fieldErrors[key]?.[0] ?? '';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function clearPersonalFormErrors() {
|
||||
personalFormErrors.first_name = '';
|
||||
personalFormErrors.last_name = '';
|
||||
personalFormErrors.address = '';
|
||||
personalFormErrors.cap = '';
|
||||
personalFormErrors.city = '';
|
||||
personalFormErrors.country = '';
|
||||
}
|
||||
|
||||
async function focusFirstField() {
|
||||
await nextTick();
|
||||
if (editTab.value === 'profile') {
|
||||
displayNameInputRef.value?.focus();
|
||||
} else {
|
||||
firstNameInputRef.value?.focus();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
97
frontend/src/pages/admin/dialogs/UpdatePasswordDialog.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<q-dialog v-model="open" @show="focusFirstField">
|
||||
<q-card style="min-width: 350px">
|
||||
<q-card-section>
|
||||
<div class="text-h6">{{ t('actions.updatePassword') }}</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section v-if="profile" class="q-gutter-md">
|
||||
<div class="text-subtitle1">{{ profile.email }}</div>
|
||||
|
||||
<q-input
|
||||
ref="passwordInputRef"
|
||||
v-model="password"
|
||||
:label="t('fields.password')"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
autocomplete="new-password"
|
||||
:error="Boolean(passwordError)"
|
||||
:error-message="passwordError"
|
||||
outlined
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-icon
|
||||
:name="showPassword ? 'visibility_off' : 'visibility'"
|
||||
class="cursor-pointer"
|
||||
@click="showPassword = !showPassword"
|
||||
/>
|
||||
</template>
|
||||
</q-input>
|
||||
|
||||
<q-banner v-if="adminStore.error" class="bg-negative text-white" rounded>
|
||||
{{ adminStore.error }}
|
||||
</q-banner>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="right">
|
||||
<q-btn v-close-popup flat :label="t('actions.cancel')" />
|
||||
<q-btn color="primary" :label="t('actions.save')" :loading="adminStore.loading" @click="save" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Notify } from 'quasar';
|
||||
import { useAdminStore, type Profile } from '@/stores/admin-store';
|
||||
|
||||
type Focusable = { focus: () => void };
|
||||
|
||||
const props = defineProps<{
|
||||
profile: Profile | null;
|
||||
}>();
|
||||
|
||||
const open = defineModel<boolean>({ required: true });
|
||||
const adminStore = useAdminStore();
|
||||
const { t } = useI18n();
|
||||
const password = ref('');
|
||||
const passwordError = ref('');
|
||||
const showPassword = ref(false);
|
||||
const passwordInputRef = ref<Focusable | null>(null);
|
||||
|
||||
watch(
|
||||
() => [open.value, props.profile] as const,
|
||||
([isOpen]) => {
|
||||
if (isOpen) {
|
||||
password.value = '';
|
||||
passwordError.value = '';
|
||||
showPassword.value = false;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
async function save() {
|
||||
if (!props.profile) return;
|
||||
passwordError.value = '';
|
||||
|
||||
if (!password.value) {
|
||||
passwordError.value = t('fields.password');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await adminStore.updateProfilePassword(props.profile.user_id, { password: password.value });
|
||||
Notify.create({ type: 'positive', message: t('admin.notifications.passwordUpdated') });
|
||||
open.value = false;
|
||||
} catch {
|
||||
// adminStore.error already holds the failure message
|
||||
}
|
||||
}
|
||||
|
||||
async function focusFirstField() {
|
||||
await nextTick();
|
||||
passwordInputRef.value?.focus();
|
||||
}
|
||||
</script>
|
||||
78
frontend/src/pages/admin/dialogs/UpdateRoleDialog.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<q-dialog v-model="open" @show="focusFirstField">
|
||||
<q-card style="min-width: 350px">
|
||||
<q-card-section>
|
||||
<div class="text-h6">{{ t('actions.updateRole') }}</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section v-if="profile" class="q-gutter-md">
|
||||
<div class="text-subtitle1">{{ profile.email }}</div>
|
||||
|
||||
<q-select
|
||||
ref="roleSelectRef"
|
||||
v-model="selectedRole"
|
||||
:options="adminStore.roles"
|
||||
option-label="name"
|
||||
option-value="value"
|
||||
emit-value
|
||||
map-options
|
||||
:label="t('fields.role')"
|
||||
/>
|
||||
|
||||
<q-banner v-if="adminStore.error" class="bg-negative text-white" rounded>
|
||||
{{ adminStore.error }}
|
||||
</q-banner>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="right">
|
||||
<q-btn v-close-popup flat :label="t('actions.cancel')" />
|
||||
<q-btn color="primary" :label="t('actions.save')" :loading="adminStore.loading" @click="save" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Notify } from 'quasar';
|
||||
import { useAdminStore, type Profile } from '@/stores/admin-store';
|
||||
|
||||
type Focusable = { focus: () => void };
|
||||
|
||||
const props = defineProps<{
|
||||
profile: Profile | null;
|
||||
}>();
|
||||
|
||||
const open = defineModel<boolean>({ required: true });
|
||||
const adminStore = useAdminStore();
|
||||
const { t } = useI18n();
|
||||
const selectedRole = ref('');
|
||||
const roleSelectRef = ref<Focusable | null>(null);
|
||||
|
||||
watch(
|
||||
() => [open.value, props.profile] as const,
|
||||
([isOpen, profile]) => {
|
||||
if (isOpen && profile) {
|
||||
selectedRole.value = profile.role;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
async function save() {
|
||||
if (!props.profile) return;
|
||||
try {
|
||||
await adminStore.updateProfileRole(props.profile.user_id, selectedRole.value);
|
||||
Notify.create({ type: 'positive', message: t('admin.notifications.roleUpdated') });
|
||||
open.value = false;
|
||||
} catch {
|
||||
// adminStore.error already holds the failure message
|
||||
}
|
||||
}
|
||||
|
||||
async function focusFirstField() {
|
||||
await nextTick();
|
||||
roleSelectRef.value?.focus();
|
||||
}
|
||||
</script>
|
||||
79
frontend/src/pages/admin/dialogs/UpdateStatusDialog.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<q-dialog v-model="open" @show="focusFirstField">
|
||||
<q-card style="min-width: 350px">
|
||||
<q-card-section>
|
||||
<div class="text-h6">{{ t('actions.updateStatus') }}</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section v-if="profile" class="q-gutter-md">
|
||||
<div class="text-subtitle1">{{ profile.email }}</div>
|
||||
|
||||
<q-select
|
||||
ref="statusSelectRef"
|
||||
v-model="selectedStatus"
|
||||
:options="adminStore.updatableStatuses"
|
||||
option-label="name"
|
||||
option-value="value"
|
||||
emit-value
|
||||
map-options
|
||||
:label="t('fields.status')"
|
||||
/>
|
||||
|
||||
<q-banner v-if="adminStore.error" class="bg-negative text-white" rounded>
|
||||
{{ adminStore.error }}
|
||||
</q-banner>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="right">
|
||||
<q-btn v-close-popup flat :label="t('actions.cancel')" />
|
||||
<q-btn color="primary" :label="t('actions.save')" :loading="adminStore.loading" @click="save" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Notify } from 'quasar';
|
||||
import { useAdminStore, type Profile } from '@/stores/admin-store';
|
||||
import type { Status } from '@/stores/profiles-store';
|
||||
|
||||
type Focusable = { focus: () => void };
|
||||
|
||||
const props = defineProps<{
|
||||
profile: Profile | null;
|
||||
}>();
|
||||
|
||||
const open = defineModel<boolean>({ required: true });
|
||||
const adminStore = useAdminStore();
|
||||
const { t } = useI18n();
|
||||
const selectedStatus = ref<Status>(0);
|
||||
const statusSelectRef = ref<Focusable | null>(null);
|
||||
|
||||
watch(
|
||||
() => [open.value, props.profile] as const,
|
||||
([isOpen, profile]) => {
|
||||
if (isOpen && profile) {
|
||||
selectedStatus.value = profile.status;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
async function save() {
|
||||
if (!props.profile) return;
|
||||
try {
|
||||
await adminStore.updateProfileStatus(props.profile.user_id, selectedStatus.value);
|
||||
Notify.create({ type: 'positive', message: t('admin.notifications.statusUpdated') });
|
||||
open.value = false;
|
||||
} catch {
|
||||
// adminStore.error already holds the failure message
|
||||
}
|
||||
}
|
||||
|
||||
async function focusFirstField() {
|
||||
await nextTick();
|
||||
statusSelectRef.value?.focus();
|
||||
}
|
||||
</script>
|
||||
36
frontend/src/router/index.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { defineRouter } from '#q-app';
|
||||
import {
|
||||
createMemoryHistory,
|
||||
createRouter,
|
||||
createWebHashHistory,
|
||||
createWebHistory,
|
||||
} from 'vue-router';
|
||||
|
||||
import routes from './routes';
|
||||
|
||||
/*
|
||||
* If not building with SSR mode, you can
|
||||
* directly export the Router instantiation;
|
||||
*
|
||||
* The function below can be async too; either use
|
||||
* async/await or return a Promise which resolves
|
||||
* with the Router instance.
|
||||
*/
|
||||
|
||||
export default defineRouter((/* { store, ssrContext } */) => {
|
||||
const createHistory = import.meta.env.QUASAR_SERVER
|
||||
? createMemoryHistory
|
||||
: (import.meta.env.QUASAR_VUE_ROUTER_MODE === 'history' ? createWebHistory : createWebHashHistory);
|
||||
|
||||
const Router = createRouter({
|
||||
scrollBehavior: () => ({ left: 0, top: 0 }),
|
||||
routes,
|
||||
|
||||
// Leave this as is and make changes in quasar.conf.js instead!
|
||||
// quasar.conf.js -> build -> vueRouterMode
|
||||
// quasar.conf.js -> build -> publicPath
|
||||
history: createHistory(import.meta.env.QUASAR_VUE_ROUTER_BASE)
|
||||
});
|
||||
|
||||
return Router;
|
||||
});
|
||||
34
frontend/src/router/routes.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/layouts/MainLayout.vue'),
|
||||
children: [
|
||||
{ path: '', component: () => import('@/pages/IndexPage.vue') },
|
||||
{ path: 'second', component: () => import('@/pages/SecondPage.vue') },
|
||||
{ path: 'login', component: () => import('@/pages/LoginPage.vue') },
|
||||
{ path: 'password-reset', component: () => import('@/pages/PasswordResetPage.vue') },
|
||||
{ path: 'register', component: () => import('@/pages/RegisterPage.vue') },
|
||||
{ path: 'welcome', component: () => import('@/pages/WelcomePage.vue') },
|
||||
{ path: 'profile', component: () => import('@/pages/ProfilePage.vue') },
|
||||
{ path: '401', component: () => import('@/pages/ErrorUnauthorized.vue') },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
component: () => import('@/layouts/AdminLayout.vue'),
|
||||
children: [
|
||||
{ path: 'dashboard', component: () => import('@/pages/admin/DashboardPage.vue') },
|
||||
{ path: 'profiles', component: () => import('@/pages/admin/ProfilesPage.vue') }
|
||||
],
|
||||
},
|
||||
// Always leave this as last one,
|
||||
// but you can also remove it
|
||||
{
|
||||
path: '/:catchAll(.*)*',
|
||||
component: () => import('@/pages/ErrorNotFound.vue'),
|
||||
},
|
||||
];
|
||||
|
||||
export default routes;
|
||||
159
frontend/src/stores/admin-store.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
import Client, { Local, admin as AdminNS, auth as AuthNS, profiles as ProfilesNS } from '@/encore/client';
|
||||
import { useProfilesStore } from '@/stores/profiles-store';
|
||||
|
||||
export type Profile = AdminNS.Profile;
|
||||
export type ProfileParams = AdminNS.ProfileParams;
|
||||
export type RegisterParams = AdminNS.RegisterParams;
|
||||
export type PersonalData = AdminNS.PersonalData;
|
||||
export type PersonalDataParams = AdminNS.PersonalDataParams;
|
||||
export type UpdateProfilePasswordParams = AdminNS.UpdateProfilePasswordParams;
|
||||
export type RoleOption = AuthNS.RoleOption;
|
||||
export type StatusOption = ProfilesNS.StatusOption;
|
||||
|
||||
export const useAdminStore = defineStore('admin', () => {
|
||||
const profiles = ref<Profile[]>([]);
|
||||
const roles = ref<RoleOption[]>([]);
|
||||
const statuses = ref<StatusOption[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
const profilesStore = useProfilesStore();
|
||||
|
||||
const client = new Client(Local, {
|
||||
auth: () => (profilesStore.token ? { Authorization: `Bearer ${profilesStore.token}` } : undefined),
|
||||
});
|
||||
|
||||
async function withLoading<T>(fn: () => Promise<T>): Promise<T> {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
throw err;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetches all profiles into `profiles`. Requires the caller to be logged in as an admin. */
|
||||
async function listProfiles(): Promise<Profile[]> {
|
||||
return withLoading(async () => {
|
||||
const res = await client.admin.ListProfiles();
|
||||
profiles.value = res.profiles;
|
||||
return res.profiles;
|
||||
});
|
||||
}
|
||||
|
||||
/** Creates a new user profile with credentials. Requires the caller to be logged in as an admin. */
|
||||
async function insertProfile(params: RegisterParams): Promise<Profile> {
|
||||
return withLoading(async () => {
|
||||
const res = await client.admin.InsertProfile(params);
|
||||
profiles.value.push(res);
|
||||
return res;
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetches the valid roles and profile statuses into `roles` and `statuses`. */
|
||||
async function fetchSystemOptions(): Promise<void> {
|
||||
return withLoading(async () => {
|
||||
const res = await client.admin.GetSystemOptions();
|
||||
roles.value = res.roles;
|
||||
statuses.value = res.statuses;
|
||||
});
|
||||
}
|
||||
|
||||
/** Statuses that an admin can manually set on a profile. */
|
||||
const updatableStatuses = computed(() => statuses.value.filter((s) => s.updatable));
|
||||
|
||||
function replaceProfile(updated: Profile) {
|
||||
const idx = profiles.value.findIndex((p) => p.user_id === updated.user_id);
|
||||
if (idx !== -1) {
|
||||
profiles.value[idx] = updated;
|
||||
}
|
||||
}
|
||||
|
||||
/** Updates any user's profile. Requires the caller to be logged in as an admin. */
|
||||
async function updateProfile(userID: string, params: ProfileParams): Promise<Profile> {
|
||||
return withLoading(async () => {
|
||||
const res = await client.admin.UpdateProfile(userID, params);
|
||||
replaceProfile(res);
|
||||
return res;
|
||||
});
|
||||
}
|
||||
|
||||
/** Updates the role of any user's profile. */
|
||||
async function updateProfileRole(userID: string, role: string): Promise<Profile> {
|
||||
return withLoading(async () => {
|
||||
const res = await client.admin.UpdateProfileRole(userID, { role });
|
||||
replaceProfile(res);
|
||||
return res;
|
||||
});
|
||||
}
|
||||
|
||||
/** Updates the status of any user's profile. */
|
||||
async function updateProfileStatus(userID: string, status: ProfilesNS.Status): Promise<Profile> {
|
||||
return withLoading(async () => {
|
||||
const res = await client.admin.UpdateProfileStatus(userID, { status });
|
||||
replaceProfile(res);
|
||||
return res;
|
||||
});
|
||||
}
|
||||
|
||||
/** Updates the password of any user's profile. */
|
||||
async function updateProfilePassword(userID: string, params: UpdateProfilePasswordParams): Promise<void> {
|
||||
return withLoading(async () => {
|
||||
await client.admin.UpdateProfilePassword(userID, params);
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetches the personal data of any user. Requires the caller to be logged in as an admin. */
|
||||
async function getPersonalData(userID: string): Promise<PersonalData> {
|
||||
return withLoading(async () => {
|
||||
return await client.admin.GetPersonalData(userID);
|
||||
});
|
||||
}
|
||||
|
||||
/** Creates or replaces the personal data of any user. */
|
||||
async function upsertPersonalData(userID: string, params: PersonalDataParams): Promise<PersonalData> {
|
||||
return withLoading(async () => {
|
||||
return await client.admin.UpsertPersonalData(userID, params);
|
||||
});
|
||||
}
|
||||
|
||||
/** Uploads an avatar image and returns its public URL. */
|
||||
async function uploadAvatar(image: Blob): Promise<string> {
|
||||
return withLoading(async () => {
|
||||
const resp = await client.profiles.UploadAvatar('POST', image);
|
||||
if (!resp.ok) {
|
||||
throw new Error(`avatar upload failed (${resp.status})`);
|
||||
}
|
||||
const data = (await resp.json()) as { url: string };
|
||||
return data.url;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
// state
|
||||
profiles,
|
||||
roles,
|
||||
statuses,
|
||||
loading,
|
||||
error,
|
||||
// getters
|
||||
updatableStatuses,
|
||||
// actions
|
||||
listProfiles,
|
||||
fetchSystemOptions,
|
||||
insertProfile,
|
||||
updateProfile,
|
||||
updateProfileRole,
|
||||
updateProfileStatus,
|
||||
updateProfilePassword,
|
||||
getPersonalData,
|
||||
upsertPersonalData,
|
||||
uploadAvatar,
|
||||
};
|
||||
});
|
||||
32
frontend/src/stores/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { defineStore } from '#q-app';
|
||||
import { createPinia } from 'pinia';
|
||||
|
||||
/*
|
||||
* When adding new properties to stores, you should also
|
||||
* extend the `PiniaCustomProperties` interface.
|
||||
* @see https://pinia.vuejs.org/core-concepts/plugins.html#Typing-new-store-properties
|
||||
*/
|
||||
declare module 'pinia' {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
export interface PiniaCustomProperties {
|
||||
// add your custom properties here, if any
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* If not building with SSR mode, you can
|
||||
* directly export the Store instantiation;
|
||||
*
|
||||
* The function below can be async too; either use
|
||||
* async/await or return a Promise which resolves
|
||||
* with the Store instance.
|
||||
*/
|
||||
|
||||
export default defineStore((/* { ssrContext } */) => {
|
||||
const pinia = createPinia();
|
||||
|
||||
// You can add Pinia plugins here
|
||||
// pinia.use(SomePiniaPlugin)
|
||||
|
||||
return pinia;
|
||||
});
|
||||
62
frontend/src/stores/layout-store.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
|
||||
export const useLayoutStore = defineStore('layout', () => {
|
||||
const bodyWidth = ref(0);
|
||||
const bodyHeight = ref(0);
|
||||
|
||||
let bodyResizeObserver: ResizeObserver | null = null;
|
||||
let listeners = 0;
|
||||
|
||||
function updateBodySize() {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = document.body.getBoundingClientRect();
|
||||
bodyWidth.value = Math.floor(rect.width);
|
||||
bodyHeight.value = Math.floor(rect.height);
|
||||
}
|
||||
|
||||
function startBodyResizeListener() {
|
||||
listeners += 1;
|
||||
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
updateBodySize();
|
||||
|
||||
if (bodyResizeObserver) {
|
||||
return;
|
||||
}
|
||||
|
||||
bodyResizeObserver = new ResizeObserver(() => {
|
||||
updateBodySize();
|
||||
});
|
||||
bodyResizeObserver.observe(document.body);
|
||||
window.addEventListener('resize', updateBodySize);
|
||||
}
|
||||
|
||||
function stopBodyResizeListener() {
|
||||
listeners = Math.max(0, listeners - 1);
|
||||
|
||||
if (listeners > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
bodyResizeObserver?.disconnect();
|
||||
bodyResizeObserver = null;
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('resize', updateBodySize);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
bodyWidth,
|
||||
bodyHeight,
|
||||
startBodyResizeListener,
|
||||
stopBodyResizeListener,
|
||||
};
|
||||
});
|
||||
176
frontend/src/stores/profiles-store.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
import { LocalStorage } from 'quasar';
|
||||
import Client, { Local, auth as AuthNS, profiles as ProfilesNS } from '@/encore/client';
|
||||
|
||||
export type Profile = ProfilesNS.Profile;
|
||||
export type ProfileParams = ProfilesNS.ProfileParams;
|
||||
export type RegisterParams = ProfilesNS.RegisterParams;
|
||||
export type LoginParams = ProfilesNS.LoginParams;
|
||||
export type LoginResponse = ProfilesNS.LoginResponse;
|
||||
export type Status = ProfilesNS.Status;
|
||||
export type PersonalData = ProfilesNS.PersonalData;
|
||||
export type PersonalDataParams = ProfilesNS.PersonalDataParams;
|
||||
export type SetPasswordParams = AuthNS.SetPasswordParams;
|
||||
|
||||
/** Display info for each Status value, mirroring profiles/status.go. */
|
||||
export const STATUS_INFO: { label: string; color: string }[] = [
|
||||
{ label: 'Active', color: 'positive' },
|
||||
{ label: 'Inactive', color: 'grey' },
|
||||
{ label: 'Suspended', color: 'warning' },
|
||||
{ label: 'Deleted', color: 'negative' },
|
||||
{ label: 'Pending', color: 'info' },
|
||||
{ label: 'Waiting deletion', color: 'warning' },
|
||||
{ label: 'Banned', color: 'negative' },
|
||||
];
|
||||
|
||||
export function statusInfo(status: Status): { label: string; color: string } {
|
||||
return STATUS_INFO[status] ?? { label: `Unknown (${status})`, color: 'grey' };
|
||||
}
|
||||
|
||||
const TOKEN_STORAGE_KEY = 'profiles.token';
|
||||
|
||||
export const useProfilesStore = defineStore('profiles', () => {
|
||||
const token = ref<string | null>(LocalStorage.getItem(TOKEN_STORAGE_KEY));
|
||||
const profile = ref<Profile | null>(null);
|
||||
const personalData = ref<PersonalData | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
const isAuthenticated = computed(() => token.value !== null);
|
||||
|
||||
const client = new Client(Local, {
|
||||
auth: () => (token.value ? { Authorization: `Bearer ${token.value}` } : undefined),
|
||||
});
|
||||
|
||||
async function withLoading<T>(fn: () => Promise<T>): Promise<T> {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
throw err;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function setToken(newToken: string | null) {
|
||||
token.value = newToken;
|
||||
if (newToken) {
|
||||
LocalStorage.set(TOKEN_STORAGE_KEY, newToken);
|
||||
} else {
|
||||
LocalStorage.remove(TOKEN_STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
/** Logs in and fetches the resulting profile into `profile`. */
|
||||
async function login(params: LoginParams): Promise<LoginResponse> {
|
||||
return withLoading(async () => {
|
||||
const res = await client.profiles.Login(params);
|
||||
setToken(res.token);
|
||||
await me();
|
||||
return res;
|
||||
});
|
||||
}
|
||||
|
||||
/** Clears the local session. Does not call the auth service's Logout endpoint. */
|
||||
function logout() {
|
||||
setToken(null);
|
||||
profile.value = null;
|
||||
personalData.value = null;
|
||||
}
|
||||
|
||||
/** Fetches the authenticated caller's profile, or null if not authenticated. */
|
||||
async function me(): Promise<Profile | null> {
|
||||
return withLoading(async () => {
|
||||
const res = await client.profiles.Me();
|
||||
profile.value = res;
|
||||
return res;
|
||||
});
|
||||
}
|
||||
|
||||
/** Registers a new profile (and credentials) and returns it. */
|
||||
async function register(params: RegisterParams): Promise<Profile> {
|
||||
return withLoading(async () => {
|
||||
return await client.profiles.Insert(params);
|
||||
});
|
||||
}
|
||||
|
||||
/** Updates the authenticated caller's own profile. */
|
||||
async function update(params: ProfileParams): Promise<Profile> {
|
||||
return withLoading(async () => {
|
||||
const res = await client.profiles.Update(params);
|
||||
profile.value = res;
|
||||
return res;
|
||||
});
|
||||
}
|
||||
|
||||
/** Deletes the authenticated caller's own profile and clears the session. */
|
||||
async function remove(): Promise<void> {
|
||||
return withLoading(async () => {
|
||||
await client.profiles.Delete();
|
||||
logout();
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetches the authenticated caller's own personal data into `personalData`. */
|
||||
async function fetchPersonalData(): Promise<PersonalData> {
|
||||
return withLoading(async () => {
|
||||
const res = await client.profiles.GetPersonalData();
|
||||
personalData.value = res;
|
||||
return res;
|
||||
});
|
||||
}
|
||||
|
||||
/** Creates or replaces the authenticated caller's own personal data. */
|
||||
async function savePersonalData(params: PersonalDataParams): Promise<PersonalData> {
|
||||
return withLoading(async () => {
|
||||
const res = await client.profiles.UpsertPersonalData(params);
|
||||
personalData.value = res;
|
||||
return res;
|
||||
});
|
||||
}
|
||||
|
||||
/** Changes the authenticated caller's password after verifying the current one. */
|
||||
async function setPassword(params: SetPasswordParams): Promise<void> {
|
||||
return withLoading(async () => {
|
||||
await client.auth.SetPassword(params);
|
||||
});
|
||||
}
|
||||
|
||||
/** Uploads an avatar image and returns its public URL. */
|
||||
async function uploadAvatar(image: Blob): Promise<string> {
|
||||
return withLoading(async () => {
|
||||
const resp = await client.profiles.UploadAvatar('POST', image);
|
||||
if (!resp.ok) {
|
||||
throw new Error(`avatar upload failed (${resp.status})`);
|
||||
}
|
||||
const data = (await resp.json()) as { url: string };
|
||||
return data.url;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
// state
|
||||
token,
|
||||
profile,
|
||||
personalData,
|
||||
loading,
|
||||
error,
|
||||
// getters
|
||||
isAuthenticated,
|
||||
// actions
|
||||
login,
|
||||
logout,
|
||||
me,
|
||||
register,
|
||||
update,
|
||||
remove,
|
||||
fetchPersonalData,
|
||||
savePersonalData,
|
||||
setPassword,
|
||||
uploadAvatar,
|
||||
};
|
||||
});
|
||||
327
frontend/tools/zod-sync.mjs
Normal file
@@ -0,0 +1,327 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import ts from 'typescript';
|
||||
|
||||
const rootDir = process.cwd();
|
||||
const clientPath = path.join(rootDir, 'src/encore/client.ts');
|
||||
const outputPath = path.join(rootDir, 'src/encore/zod.ts');
|
||||
|
||||
const clientSource = fs.readFileSync(clientPath, 'utf8');
|
||||
const clientFile = ts.createSourceFile(clientPath, clientSource, ts.ScriptTarget.Latest, true);
|
||||
const existingSource = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, 'utf8') : '';
|
||||
const existingFile = ts.createSourceFile(outputPath, existingSource, ts.ScriptTarget.Latest, true);
|
||||
|
||||
const schemas = collectEncoreSchemas(clientFile);
|
||||
const existingSchemas = collectExistingSchemas(existingFile, existingSource);
|
||||
const generated = renderOutput(schemas, existingSchemas);
|
||||
|
||||
fs.writeFileSync(outputPath, generated);
|
||||
console.log(`Synced ${schemas.length} Zod schemas to ${path.relative(rootDir, outputPath)}`);
|
||||
|
||||
function collectEncoreSchemas(sourceFile) {
|
||||
const found = [];
|
||||
const typeAliases = new Map();
|
||||
const writableParamTypes = collectWritableParamTypes(sourceFile);
|
||||
|
||||
for (const statement of sourceFile.statements) {
|
||||
if (!isExportedNamespace(statement)) continue;
|
||||
const namespaceName = statement.name.text;
|
||||
const body = statement.body;
|
||||
if (!body || !ts.isModuleBlock(body)) continue;
|
||||
|
||||
for (const child of body.statements) {
|
||||
if (ts.isTypeAliasDeclaration(child) && isExported(child)) {
|
||||
typeAliases.set(`${namespaceName}.${child.name.text}`, child.type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const statement of sourceFile.statements) {
|
||||
if (!isExportedNamespace(statement)) continue;
|
||||
const namespaceName = statement.name.text;
|
||||
const body = statement.body;
|
||||
if (!body || !ts.isModuleBlock(body)) continue;
|
||||
|
||||
for (const child of body.statements) {
|
||||
const qualifiedName = `${namespaceName}.${child.name?.text ?? ''}`;
|
||||
if (!writableParamTypes.has(qualifiedName)) continue;
|
||||
|
||||
if (ts.isInterfaceDeclaration(child) && isExported(child)) {
|
||||
found.push({
|
||||
kind: 'object',
|
||||
namespaceName,
|
||||
typeName: child.name.text,
|
||||
schemaName: schemaName(namespaceName, child.name.text),
|
||||
fields: child.members
|
||||
.filter(ts.isPropertySignature)
|
||||
.map((member) => ({
|
||||
name: propertyName(member.name),
|
||||
zod: zodForType(member.type, namespaceName, typeAliases),
|
||||
}))
|
||||
.filter((field) => field.name),
|
||||
});
|
||||
}
|
||||
|
||||
if (ts.isTypeAliasDeclaration(child) && isExported(child)) {
|
||||
found.push({
|
||||
kind: 'alias',
|
||||
namespaceName,
|
||||
typeName: child.name.text,
|
||||
schemaName: schemaName(namespaceName, child.name.text),
|
||||
zod: zodForType(child.type, namespaceName, typeAliases),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
function collectWritableParamTypes(sourceFile) {
|
||||
const found = new Set();
|
||||
|
||||
for (const statement of sourceFile.statements) {
|
||||
if (!isExportedNamespace(statement)) continue;
|
||||
const namespaceName = statement.name.text;
|
||||
const body = statement.body;
|
||||
if (!body || !ts.isModuleBlock(body)) continue;
|
||||
|
||||
for (const child of body.statements) {
|
||||
if (!ts.isClassDeclaration(child) || child.name?.text !== 'ServiceClient') continue;
|
||||
|
||||
for (const member of child.members) {
|
||||
if (!ts.isMethodDeclaration(member)) continue;
|
||||
const httpMethod = writableHttpMethod(member);
|
||||
if (!httpMethod) continue;
|
||||
|
||||
const bodyParamName = jsonStringifiedParamName(member);
|
||||
if (!bodyParamName) continue;
|
||||
|
||||
const param = member.parameters.find((candidate) => propertyName(candidate.name) === bodyParamName);
|
||||
if (!param?.type || !ts.isTypeReferenceNode(param.type)) continue;
|
||||
|
||||
const ref = typeNameText(param.type.typeName);
|
||||
const qualifiedRef = ref.includes('.') ? ref : `${namespaceName}.${ref}`;
|
||||
found.add(qualifiedRef);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
function writableHttpMethod(method) {
|
||||
let result = '';
|
||||
|
||||
visit(method.body);
|
||||
return result;
|
||||
|
||||
function visit(node) {
|
||||
if (result || !node) return;
|
||||
|
||||
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
|
||||
const callName = node.expression.name.text;
|
||||
if (callName === 'callTypedAPI' || callName === 'callAPI') {
|
||||
const [methodArg] = node.arguments;
|
||||
if (methodArg && ts.isStringLiteral(methodArg) && ['POST', 'PUT'].includes(methodArg.text)) {
|
||||
result = methodArg.text;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
}
|
||||
|
||||
function jsonStringifiedParamName(method) {
|
||||
let result = '';
|
||||
|
||||
visit(method.body);
|
||||
return result;
|
||||
|
||||
function visit(node) {
|
||||
if (result || !node) return;
|
||||
|
||||
if (
|
||||
ts.isCallExpression(node) &&
|
||||
ts.isPropertyAccessExpression(node.expression) &&
|
||||
ts.isIdentifier(node.expression.expression) &&
|
||||
node.expression.expression.text === 'JSON' &&
|
||||
node.expression.name.text === 'stringify'
|
||||
) {
|
||||
const [arg] = node.arguments;
|
||||
if (arg && ts.isIdentifier(arg)) {
|
||||
result = arg.text;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
}
|
||||
|
||||
function collectExistingSchemas(sourceFile, sourceText) {
|
||||
const result = new Map();
|
||||
|
||||
for (const statement of sourceFile.statements) {
|
||||
if (!ts.isVariableStatement(statement) || !isExported(statement)) continue;
|
||||
|
||||
for (const declaration of statement.declarationList.declarations) {
|
||||
if (!ts.isIdentifier(declaration.name) || !declaration.initializer) continue;
|
||||
if (!declaration.name.text.endsWith('Schema')) continue;
|
||||
|
||||
const objectLiteral = zodObjectLiteral(declaration.initializer);
|
||||
if (!objectLiteral) continue;
|
||||
|
||||
const fields = new Map();
|
||||
for (const prop of objectLiteral.properties) {
|
||||
if (!ts.isPropertyAssignment(prop)) continue;
|
||||
const name = propertyName(prop.name);
|
||||
if (!name) continue;
|
||||
fields.set(name, prop.initializer.getText(sourceFile));
|
||||
}
|
||||
|
||||
result.set(declaration.name.text, { fields });
|
||||
}
|
||||
}
|
||||
|
||||
if (!sourceText.trim()) return result;
|
||||
return result;
|
||||
}
|
||||
|
||||
function renderOutput(schemas, existingSchemas) {
|
||||
const lines = [
|
||||
'// Code synced from src/encore/client.ts by frontend/tools/zod-sync.mjs.',
|
||||
'// Existing field validators are preserved when this file is synced again.',
|
||||
"import { z } from 'zod';",
|
||||
'',
|
||||
];
|
||||
|
||||
for (const schema of schemas) {
|
||||
if (schema.kind === 'alias') {
|
||||
lines.push(`export const ${schema.schemaName} = ${schema.zod};`);
|
||||
lines.push('');
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = existingSchemas.get(schema.schemaName);
|
||||
const currentNames = new Set(schema.fields.map((field) => field.name));
|
||||
lines.push(`export const ${schema.schemaName} = z.object({`);
|
||||
for (const field of schema.fields) {
|
||||
const expression = existing?.fields.get(field.name) ?? field.zod;
|
||||
lines.push(` ${quoteKey(field.name)}: ${expression},`);
|
||||
}
|
||||
for (const [name, expression] of existing?.fields ?? []) {
|
||||
if (currentNames.has(name)) continue;
|
||||
lines.push(` // TODO: no longer present in Encore type ${schema.namespaceName}.${schema.typeName}`);
|
||||
lines.push(` ${quoteKey(name)}: ${expression},`);
|
||||
}
|
||||
lines.push('});');
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return `${lines.join('\n').trimEnd()}\n`;
|
||||
}
|
||||
|
||||
function zodObjectLiteral(initializer) {
|
||||
if (!ts.isCallExpression(initializer)) return null;
|
||||
if (!ts.isPropertyAccessExpression(initializer.expression)) return null;
|
||||
if (initializer.expression.name.text !== 'object') return null;
|
||||
if (!ts.isIdentifier(initializer.expression.expression)) return null;
|
||||
if (initializer.expression.expression.text !== 'z') return null;
|
||||
const [arg] = initializer.arguments;
|
||||
return arg && ts.isObjectLiteralExpression(arg) ? arg : null;
|
||||
}
|
||||
|
||||
function zodForType(type, namespaceName, typeAliases) {
|
||||
if (!type) return 'z.unknown()';
|
||||
|
||||
if (type.kind === ts.SyntaxKind.StringKeyword) return 'z.string()';
|
||||
if (type.kind === ts.SyntaxKind.NumberKeyword) return 'z.number()';
|
||||
if (type.kind === ts.SyntaxKind.BooleanKeyword) return 'z.boolean()';
|
||||
if (type.kind === ts.SyntaxKind.AnyKeyword) return 'z.any()';
|
||||
if (type.kind === ts.SyntaxKind.UnknownKeyword) return 'z.unknown()';
|
||||
|
||||
if (ts.isArrayTypeNode(type)) {
|
||||
return `z.array(${zodForType(type.elementType, namespaceName, typeAliases)})`;
|
||||
}
|
||||
|
||||
if (ts.isUnionTypeNode(type)) {
|
||||
const literals = type.types.filter(ts.isLiteralTypeNode);
|
||||
if (literals.length === type.types.length && literals.length > 0) {
|
||||
return `z.union([${literals.map((literal) => zodLiteral(literal)).join(', ')}])`;
|
||||
}
|
||||
return 'z.unknown()';
|
||||
}
|
||||
|
||||
if (ts.isTypeLiteralNode(type)) {
|
||||
const fields = type.members
|
||||
.filter(ts.isPropertySignature)
|
||||
.map((member) => `${quoteKey(propertyName(member.name))}: ${zodForType(member.type, namespaceName, typeAliases)}`);
|
||||
return `z.object({ ${fields.join(', ')} })`;
|
||||
}
|
||||
|
||||
if (ts.isTypeReferenceNode(type)) {
|
||||
const ref = typeNameText(type.typeName);
|
||||
const qualifiedRef = ref.includes('.') ? ref : `${namespaceName}.${ref}`;
|
||||
const aliasType = typeAliases.get(qualifiedRef);
|
||||
if (aliasType) return zodForType(aliasType, namespaceName, typeAliases);
|
||||
return `z.lazy(() => ${schemaNameFromReference(ref, namespaceName)})`;
|
||||
}
|
||||
|
||||
return 'z.unknown()';
|
||||
}
|
||||
|
||||
function zodLiteral(literal) {
|
||||
const node = literal.literal;
|
||||
if (ts.isStringLiteral(node)) return `z.literal(${JSON.stringify(node.text)})`;
|
||||
if (ts.isNumericLiteral(node)) return `z.literal(${node.text})`;
|
||||
if (node.kind === ts.SyntaxKind.TrueKeyword) return 'z.literal(true)';
|
||||
if (node.kind === ts.SyntaxKind.FalseKeyword) return 'z.literal(false)';
|
||||
return 'z.unknown()';
|
||||
}
|
||||
|
||||
function schemaNameFromReference(ref, namespaceName) {
|
||||
if (ref.includes('.')) {
|
||||
const [ns, name] = ref.split('.');
|
||||
return schemaName(ns, name);
|
||||
}
|
||||
return schemaName(namespaceName, ref);
|
||||
}
|
||||
|
||||
function schemaName(namespaceName, typeName) {
|
||||
return `${pascal(namespaceName)}${pascal(typeName)}Schema`;
|
||||
}
|
||||
|
||||
function propertyName(name) {
|
||||
if (!name) return '';
|
||||
if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) return name.text;
|
||||
return '';
|
||||
}
|
||||
|
||||
function quoteKey(key) {
|
||||
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : JSON.stringify(key);
|
||||
}
|
||||
|
||||
function typeNameText(name) {
|
||||
if (ts.isIdentifier(name)) return name.text;
|
||||
if (ts.isQualifiedName(name)) return `${typeNameText(name.left)}.${name.right.text}`;
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function pascal(value) {
|
||||
return value
|
||||
.split(/[^A-Za-z0-9]+/)
|
||||
.filter(Boolean)
|
||||
.map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
function isExportedNamespace(node) {
|
||||
return ts.isModuleDeclaration(node) && isExported(node) && ts.isIdentifier(node.name);
|
||||
}
|
||||
|
||||
function isExported(node) {
|
||||
return Boolean(node.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword));
|
||||
}
|
||||
3
frontend/tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "./.quasar/tsconfig.json"
|
||||
}
|
||||
17
go.mod
Normal file
@@ -0,0 +1,17 @@
|
||||
module encore.app
|
||||
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
encore.dev v1.57.5
|
||||
github.com/jackc/pgx/v5 v5.7.6
|
||||
golang.org/x/crypto v0.42.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
)
|
||||
30
go.sum
Normal file
@@ -0,0 +1,30 @@
|
||||
encore.dev v1.57.5 h1:gpcE2XF4Qvh0IzD+seLf9DPV9q8Mzr7bMaNJfpF2xuc=
|
||||
encore.dev v1.57.5/go.mod h1:lK8vSJG6uhYeUwT87/FEpcLdiN98QUcotd3gxRX0xDw=
|
||||
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/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
|
||||
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
62
hello/hello.go
Normal file
@@ -0,0 +1,62 @@
|
||||
// Service hello implements a simple hello world REST API.
|
||||
package hello
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// Welcome to Encore!
|
||||
// This is a simple "Hello World" project to get you started.
|
||||
//
|
||||
// To run it, execute "encore run" in your favorite shell.
|
||||
|
||||
// ==================================================================
|
||||
|
||||
// This is a public REST API that responds with a personalized greeting.
|
||||
// Learn more about defining APIs with Encore:
|
||||
// https://encore.dev/docs/primitives/services-and-apis
|
||||
//
|
||||
// To call it, run in your terminal:
|
||||
//
|
||||
// curl http://localhost:4000/hello/World
|
||||
//
|
||||
//encore:api public path=/hello/:name
|
||||
func World(ctx context.Context, name string) (*Response, error) {
|
||||
msg := "Hello, " + name + "!"
|
||||
return &Response{Message: msg}, nil
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
|
||||
// Encore comes with a built-in local development dashboard for
|
||||
// exploring your API, viewing documentation, debugging with
|
||||
// distributed tracing, and more:
|
||||
//
|
||||
// http://localhost:9400
|
||||
//
|
||||
|
||||
// ==================================================================
|
||||
|
||||
// Next steps
|
||||
//
|
||||
// 1. Deploy your application to the cloud
|
||||
//
|
||||
// git add -A .
|
||||
// git commit -m 'Commit message'
|
||||
// git push encore
|
||||
//
|
||||
// 2. To continue exploring Encore, check out some of these topics:
|
||||
//
|
||||
// Defining Services: https://encore.dev/docs/go/primitives/services
|
||||
// Defining APIs: https://encore.dev/docs/go/primitives/defining-apis
|
||||
// Using SQL databases: https://encore.dev/docs/go/primitives/databases
|
||||
// Using Pub/Sub: https://encore.dev/docs/go/primitives/pubsub
|
||||
// Authenticating users: https://encore.dev/docs/go/develop/auth
|
||||
// Building a REST API: https://encore.dev/docs/go/tutorials/rest-api
|
||||
// Building an Event-Driven app: https://encore.dev/docs/go/tutorials/uptime
|
||||
// Building a Slack bot: https://encore.dev/docs/go/tutorials/slack-bot
|
||||
// Example apps repo: https://github.com/encoredev/examples
|
||||
22
hello/hello_test.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package hello
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Run tests using `encore test`, which compiles the Encore app and then runs `go test`.
|
||||
// It supports all the same flags that the `go test` command does.
|
||||
// You automatically get tracing for tests in the local dev dash: http://localhost:9400
|
||||
// Learn more: https://encore.dev/docs/go/develop/testing
|
||||
func TestWorld(t *testing.T) {
|
||||
const in = "Jane Doe"
|
||||
resp, err := World(context.Background(), in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := resp.Message; !strings.Contains(got, in) {
|
||||
t.Errorf("World(%q) = %q, expected to contain %q", in, got, in)
|
||||
}
|
||||
}
|
||||
69
hello/index.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package hello
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"encore.dev"
|
||||
)
|
||||
|
||||
// Landing page with usage instructions.
|
||||
//
|
||||
//encore:api public raw path=/!path
|
||||
func Index(w http.ResponseWriter, req *http.Request) {
|
||||
baseUrl := encore.Meta().APIBaseURL.String()
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write([]byte(strings.ReplaceAll(landingPage, "{{baseUrl}}", baseUrl)))
|
||||
}
|
||||
|
||||
const landingPage = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Hello World</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #0a0a0a; color: #e5e5e5; padding: 2rem; max-width: 720px; margin: 0 auto; line-height: 1.6; }
|
||||
h1 { font-size: 1.75rem; margin-bottom: 0.5rem; color: #fff; }
|
||||
h2 { font-size: 1.1rem; margin-top: 2rem; margin-bottom: 0.75rem; color: #fff; }
|
||||
p { margin-bottom: 1rem; color: #a3a3a3; }
|
||||
code { background: #1a1a1a; padding: 0.15rem 0.4rem; border-radius: 4px; font-size: 0.9em; color: #e5e5e5; }
|
||||
pre { background: #1a1a1a; border: 1px solid #262626; border-radius: 8px; padding: 1rem; overflow-x: auto; margin-bottom: 1rem; }
|
||||
pre code { background: none; padding: 0; }
|
||||
.endpoint { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; }
|
||||
.method { font-size: 0.75rem; font-weight: 600; padding: 0.2rem 0.5rem; border-radius: 4px; font-family: monospace; }
|
||||
.get { background: #15803d; color: #fff; }
|
||||
.path { font-family: monospace; color: #e5e5e5; }
|
||||
.desc { color: #737373; font-size: 0.9rem; margin-bottom: 1.25rem; }
|
||||
a { color: #60a5fa; }
|
||||
.badge { display: inline-block; background: #15803d; color: #fff; font-size: 0.7rem; padding: 0.15rem 0.5rem; border-radius: 999px; margin-left: 0.5rem; font-weight: 600; vertical-align: middle; position: relative; top: -0.15em; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Hello World <span class="badge">Encore.go</span></h1>
|
||||
<p>A simple REST API to get you started with Encore. This is the simplest possible Encore app, with a single endpoint that returns a greeting.</p>
|
||||
|
||||
<p>Explore and test endpoints in the <a href="http://localhost:9400/">Local Dashboard</a> when running locally. When deployed to <a href="https://app.encore.cloud">Encore Cloud</a>, use the Service Catalog to call endpoints and view traces to see how requests flow between services.</p>
|
||||
|
||||
<h2>Try it</h2>
|
||||
|
||||
<div class="endpoint">
|
||||
<span class="method get">GET</span>
|
||||
<span class="path">/hello/:name</span>
|
||||
<code>hello.World</code>
|
||||
</div>
|
||||
<p class="desc">Returns a personalized greeting.</p>
|
||||
<pre><code>curl {{baseUrl}}/hello/World</code></pre>
|
||||
|
||||
<h2>Next steps</h2>
|
||||
<p>Check out these topics to keep building:</p>
|
||||
<p>
|
||||
<a href="https://encore.dev/docs/go/tutorials/rest-api">Building a REST API</a> ·
|
||||
<a href="https://encore.dev/docs/go/primitives/services">Defining Services</a> ·
|
||||
<a href="https://encore.dev/docs/go/primitives/databases">Using SQL Databases</a> ·
|
||||
<a href="https://encore.dev/docs/go/primitives/pubsub">Using Pub/Sub</a>
|
||||
</p>
|
||||
</body>
|
||||
</html>`
|
||||
BIN
mails/.DS_Store
vendored
Normal file
98
mails/mails.go
Normal file
@@ -0,0 +1,98 @@
|
||||
// Service mails handles transactional email delivery.
|
||||
package mails
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"encore.dev/beta/errs"
|
||||
"encore.dev/types/uuid"
|
||||
)
|
||||
|
||||
const outboxDir = "mails/outbox"
|
||||
|
||||
type TransactionalMailParams struct {
|
||||
To string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
TextBody string `json:"text_body"`
|
||||
HTMLBody string `json:"html_body"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
}
|
||||
|
||||
type TransactionalMail struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
To string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
TextBody string `json:"text_body"`
|
||||
HTMLBody string `json:"html_body"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// SendTransactional records a transactional email in the local outbox.
|
||||
// TODO: replace the file-backed outbox with an SMTP sender.
|
||||
//
|
||||
//encore:api private method=POST path=/mails/transactional
|
||||
func SendTransactional(ctx context.Context, p *TransactionalMailParams) (*TransactionalMail, error) {
|
||||
if strings.TrimSpace(p.To) == "" {
|
||||
return nil, &errs.Error{Code: errs.InvalidArgument, Message: "recipient email is required"}
|
||||
}
|
||||
if strings.TrimSpace(p.Subject) == "" {
|
||||
return nil, &errs.Error{Code: errs.InvalidArgument, Message: "email subject is required"}
|
||||
}
|
||||
|
||||
id, err := uuid.NewV4()
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "failed to generate mail id")
|
||||
}
|
||||
|
||||
mail := TransactionalMail{
|
||||
ID: id,
|
||||
To: p.To,
|
||||
Subject: p.Subject,
|
||||
TextBody: p.TextBody,
|
||||
HTMLBody: p.HTMLBody,
|
||||
Metadata: p.Metadata,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if mail.Metadata == nil {
|
||||
mail.Metadata = map[string]string{}
|
||||
}
|
||||
|
||||
if err := saveToOutbox(ctx, &mail); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &mail, nil
|
||||
}
|
||||
|
||||
func saveToOutbox(ctx context.Context, mail *TransactionalMail) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(outboxDir, 0o755); err != nil {
|
||||
return errs.WrapCode(err, errs.Internal, "failed to create mail outbox")
|
||||
}
|
||||
|
||||
payload, err := json.MarshalIndent(mail, "", " ")
|
||||
if err != nil {
|
||||
return errs.WrapCode(err, errs.Internal, "failed to encode mail")
|
||||
}
|
||||
|
||||
baseFilename := mail.CreatedAt.Format("20060102T150405Z") + "_" + mail.ID.String()
|
||||
jsonPath := filepath.Join(outboxDir, baseFilename+".json")
|
||||
if err := os.WriteFile(jsonPath, payload, 0o644); err != nil {
|
||||
return errs.WrapCode(err, errs.Internal, "failed to write mail to outbox")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(mail.HTMLBody) != "" {
|
||||
htmlPath := filepath.Join(outboxDir, baseFilename+".html")
|
||||
if err := os.WriteFile(htmlPath, []byte(mail.HTMLBody), 0o644); err != nil {
|
||||
return errs.WrapCode(err, errs.Internal, "failed to write mail html to outbox")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
3
mails/outbox/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
*.json
|
||||
*.html
|
||||
!.gitkeep
|
||||
1
mails/outbox/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
54
profiles/avatars.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package profiles
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"encore.dev/beta/errs"
|
||||
"encore.dev/storage/objects"
|
||||
"encore.dev/types/uuid"
|
||||
)
|
||||
|
||||
// avatars stores user avatar images. It is public so the returned URLs can be
|
||||
// rendered directly by browsers without authentication.
|
||||
var avatars = objects.NewBucket("avatars", objects.BucketConfig{
|
||||
Public: true,
|
||||
})
|
||||
|
||||
type uploadAvatarResponse struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// UploadAvatar stores the request body as a new avatar image and returns its
|
||||
// public URL. The caller is expected to persist the URL via the profile
|
||||
// endpoints. Each upload gets a fresh key, so re-uploading never overwrites.
|
||||
//
|
||||
//encore:api auth raw method=POST path=/profiles/avatar
|
||||
func UploadAvatar(w http.ResponseWriter, req *http.Request) {
|
||||
key, err := uuid.NewV4()
|
||||
if err != nil {
|
||||
errs.HTTPError(w, errs.WrapCode(err, errs.Internal, "failed to generate avatar key"))
|
||||
return
|
||||
}
|
||||
|
||||
contentType := req.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
|
||||
writer := avatars.Upload(req.Context(), key.String(),
|
||||
objects.WithUploadAttrs(objects.UploadAttrs{ContentType: contentType}))
|
||||
if _, err := io.Copy(writer, req.Body); err != nil {
|
||||
writer.Abort(err)
|
||||
errs.HTTPError(w, errs.WrapCode(err, errs.Internal, "failed to upload avatar"))
|
||||
return
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
errs.HTTPError(w, errs.WrapCode(err, errs.Internal, "failed to store avatar"))
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(uploadAvatarResponse{URL: avatars.PublicURL(key.String()).String()})
|
||||
}
|
||||
132
profiles/login.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package profiles
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"encore.app/auth"
|
||||
"encore.dev/beta/errs"
|
||||
"encore.dev/pubsub"
|
||||
"encore.dev/storage/sqldb"
|
||||
"encore.dev/types/uuid"
|
||||
)
|
||||
|
||||
const loginTimeout = 10 * time.Second
|
||||
|
||||
type LoginParams struct {
|
||||
UserEmail string `json:"user_email"`
|
||||
Password string `json:"password" encore:"sensitive"`
|
||||
}
|
||||
|
||||
type LoginResponse struct {
|
||||
Token uuid.UUID `json:"token"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
var loginRequests = pubsub.TopicRef[pubsub.Publisher[*auth.LoginRequested]](auth.LoginRequests)
|
||||
|
||||
// Login is the public entry point for authenticating a user. It first
|
||||
// resolves the given email to a user ID via the local profiles database.
|
||||
// The actual credential check lives in the auth service; rather than calling
|
||||
// it directly, this hands the request off over Pub/Sub (publishing to
|
||||
// auth.LoginRequests) and waits for the matching auth.LoginCompleted reply
|
||||
// on auth.LoginResults before responding to the caller.
|
||||
//
|
||||
// NOTE: the wait is implemented with an in-process registry keyed by request
|
||||
// ID, so the instance that publishes the request must be the one that
|
||||
// receives the reply. That holds for local development (a single instance)
|
||||
// but not for a horizontally scaled deployment, which would need a shared
|
||||
// mechanism instead (e.g. a results table polled with retry_until).
|
||||
//
|
||||
//encore:api public method=POST path=/auth/login
|
||||
func Login(ctx context.Context, p *LoginParams) (*LoginResponse, error) {
|
||||
var userID uuid.UUID
|
||||
err := db.QueryRow(ctx, `
|
||||
SELECT user_id FROM user_profiles WHERE email = $1
|
||||
`, p.UserEmail).Scan(&userID)
|
||||
if errors.Is(err, sqldb.ErrNoRows) {
|
||||
return nil, &errs.Error{Code: errs.Unauthenticated, Message: "invalid credentials"}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "failed to look up user")
|
||||
}
|
||||
|
||||
requestID, err := uuid.NewV4()
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "failed to generate request id")
|
||||
}
|
||||
|
||||
wait := loginWaiters.register(requestID)
|
||||
defer loginWaiters.cancel(requestID)
|
||||
|
||||
if _, err := loginRequests.Publish(ctx, &auth.LoginRequested{
|
||||
RequestID: requestID,
|
||||
UserID: userID,
|
||||
Password: p.Password,
|
||||
}); err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "failed to publish login request")
|
||||
}
|
||||
|
||||
select {
|
||||
case result := <-wait:
|
||||
if !result.OK {
|
||||
return nil, &errs.Error{Code: errs.Unauthenticated, Message: "invalid credentials"}
|
||||
}
|
||||
return &LoginResponse{Token: result.Token, ExpiresAt: result.ExpiresAt}, nil
|
||||
case <-time.After(loginTimeout):
|
||||
return nil, &errs.Error{Code: errs.DeadlineExceeded, Message: "login timed out"}
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
var _ = pubsub.NewSubscription(
|
||||
auth.LoginResults, "deliver-to-waiting-login",
|
||||
pubsub.SubscriptionConfig[*auth.LoginCompleted]{
|
||||
Handler: func(ctx context.Context, ev *auth.LoginCompleted) error {
|
||||
loginWaiters.deliver(ev)
|
||||
return nil
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// loginWaiters lets the synchronous Login handler above wait for the
|
||||
// asynchronous LoginCompleted reply that matches its RequestID.
|
||||
var loginWaiters = newWaiterRegistry()
|
||||
|
||||
type waiterRegistry struct {
|
||||
mu sync.Mutex
|
||||
waiting map[uuid.UUID]chan *auth.LoginCompleted
|
||||
}
|
||||
|
||||
func newWaiterRegistry() *waiterRegistry {
|
||||
return &waiterRegistry{waiting: make(map[uuid.UUID]chan *auth.LoginCompleted)}
|
||||
}
|
||||
|
||||
func (r *waiterRegistry) register(id uuid.UUID) <-chan *auth.LoginCompleted {
|
||||
ch := make(chan *auth.LoginCompleted, 1)
|
||||
r.mu.Lock()
|
||||
r.waiting[id] = ch
|
||||
r.mu.Unlock()
|
||||
return ch
|
||||
}
|
||||
|
||||
func (r *waiterRegistry) cancel(id uuid.UUID) {
|
||||
r.mu.Lock()
|
||||
delete(r.waiting, id)
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *waiterRegistry) deliver(ev *auth.LoginCompleted) {
|
||||
r.mu.Lock()
|
||||
ch, ok := r.waiting[ev.RequestID]
|
||||
if ok {
|
||||
delete(r.waiting, ev.RequestID)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
if ok {
|
||||
ch <- ev
|
||||
}
|
||||
}
|
||||
13
profiles/me.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package profiles
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// Me returns the profile of the currently authenticated caller, or nil if
|
||||
// the request is not authenticated.
|
||||
//
|
||||
//encore:api public method=GET path=/auth/me
|
||||
func Me(ctx context.Context) (*Profile, error) {
|
||||
return Get(ctx)
|
||||
}
|
||||
1
profiles/migrations/10_add_personal_data_cap.up.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE personal_data ADD COLUMN cap TEXT NOT NULL DEFAULT '';
|
||||
8
profiles/migrations/1_create_user_profiles.up.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE user_profiles (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
bio TEXT NOT NULL DEFAULT '',
|
||||
avatar_url TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
10
profiles/migrations/2_user_id_uuid.up.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
DROP TABLE user_profiles;
|
||||
|
||||
CREATE TABLE user_profiles (
|
||||
user_id UUID PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
bio TEXT NOT NULL DEFAULT '',
|
||||
avatar_url TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
10
profiles/migrations/3_add_auth.up.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
ALTER TABLE user_profiles ADD COLUMN password_hash TEXT NOT NULL DEFAULT '';
|
||||
|
||||
CREATE TABLE sessions (
|
||||
token UUID PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES user_profiles(user_id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX sessions_user_id_idx ON sessions (user_id);
|
||||
3
profiles/migrations/4_drop_auth_tables.up.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
DROP TABLE sessions;
|
||||
|
||||
ALTER TABLE user_profiles DROP COLUMN password_hash;
|
||||
7
profiles/migrations/5_add_email.up.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE user_profiles ADD COLUMN email TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- Existing rows predate the email column; give each a unique placeholder
|
||||
-- derived from its user ID so the uniqueness constraint below can apply.
|
||||
UPDATE user_profiles SET email = user_id || '@example.invalid' WHERE email = '';
|
||||
|
||||
CREATE UNIQUE INDEX user_profiles_email_idx ON user_profiles (email);
|
||||
1
profiles/migrations/6_add_role.up.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE user_profiles ADD COLUMN role TEXT NOT NULL DEFAULT 'user';
|
||||
1
profiles/migrations/7_add_status.up.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE user_profiles ADD COLUMN status SMALLINT NOT NULL DEFAULT 0;
|
||||
2
profiles/migrations/8_artist_profile.up.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE user_profiles DROP COLUMN bio;
|
||||
ALTER TABLE user_profiles ADD COLUMN is_artist BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
10
profiles/migrations/9_personal_data.up.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE personal_data (
|
||||
user_id UUID PRIMARY KEY REFERENCES user_profiles (user_id) ON DELETE CASCADE,
|
||||
first_name TEXT NOT NULL DEFAULT '',
|
||||
last_name TEXT NOT NULL DEFAULT '',
|
||||
address TEXT NOT NULL DEFAULT '',
|
||||
city TEXT NOT NULL DEFAULT '',
|
||||
country TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
116
profiles/personal_data.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package profiles
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"encore.dev/beta/auth"
|
||||
"encore.dev/beta/errs"
|
||||
"encore.dev/storage/sqldb"
|
||||
"encore.dev/types/uuid"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
// PersonalData holds the personal details linked one-to-one to a user profile.
|
||||
type PersonalData struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Address string `json:"address"`
|
||||
Cap string `json:"cap"`
|
||||
City string `json:"city"`
|
||||
Country string `json:"country"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// PersonalDataParams are the editable fields of a user's personal data.
|
||||
type PersonalDataParams struct {
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Address string `json:"address"`
|
||||
Cap string `json:"cap"`
|
||||
City string `json:"city"`
|
||||
Country string `json:"country"`
|
||||
}
|
||||
|
||||
// GetPersonalData returns the authenticated user's own personal data.
|
||||
//
|
||||
//encore:api auth method=GET path=/profiles/personal-data
|
||||
func GetPersonalData(ctx context.Context) (*PersonalData, error) {
|
||||
userID, err := authedUserID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pd := PersonalData{UserID: userID}
|
||||
err = db.QueryRow(ctx, `
|
||||
SELECT first_name, last_name, address, cap, city, country, created_at, updated_at
|
||||
FROM personal_data WHERE user_id = $1
|
||||
`, userID).Scan(&pd.FirstName, &pd.LastName, &pd.Address, &pd.Cap, &pd.City, &pd.Country, &pd.CreatedAt, &pd.UpdatedAt)
|
||||
if errors.Is(err, sqldb.ErrNoRows) {
|
||||
return nil, &errs.Error{Code: errs.NotFound, Message: "personal data not found"}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "failed to fetch personal data")
|
||||
}
|
||||
return &pd, nil
|
||||
}
|
||||
|
||||
// UpsertPersonalData creates or replaces the authenticated user's own personal data.
|
||||
//
|
||||
//encore:api auth method=PUT path=/profiles/personal-data
|
||||
func UpsertPersonalData(ctx context.Context, p *PersonalDataParams) (*PersonalData, error) {
|
||||
userID, err := authedUserID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pd := PersonalData{
|
||||
UserID: userID,
|
||||
FirstName: p.FirstName,
|
||||
LastName: p.LastName,
|
||||
Address: p.Address,
|
||||
Cap: p.Cap,
|
||||
City: p.City,
|
||||
Country: p.Country,
|
||||
}
|
||||
err = db.QueryRow(ctx, `
|
||||
INSERT INTO personal_data (user_id, first_name, last_name, address, cap, city, country)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (user_id) DO UPDATE
|
||||
SET first_name = EXCLUDED.first_name,
|
||||
last_name = EXCLUDED.last_name,
|
||||
address = EXCLUDED.address,
|
||||
cap = EXCLUDED.cap,
|
||||
city = EXCLUDED.city,
|
||||
country = EXCLUDED.country,
|
||||
updated_at = NOW()
|
||||
RETURNING created_at, updated_at
|
||||
`, userID, p.FirstName, p.LastName, p.Address, p.Cap, p.City, p.Country).Scan(&pd.CreatedAt, &pd.UpdatedAt)
|
||||
if isForeignKeyViolation(err) {
|
||||
return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "failed to save personal data")
|
||||
}
|
||||
return &pd, nil
|
||||
}
|
||||
|
||||
// authedUserID returns the UUID of the authenticated caller.
|
||||
func authedUserID() (uuid.UUID, error) {
|
||||
uid, ok := auth.UserID()
|
||||
if !ok {
|
||||
return uuid.Nil, &errs.Error{Code: errs.Unauthenticated, Message: "authentication required"}
|
||||
}
|
||||
userID, err := uuid.FromString(string(uid))
|
||||
if err != nil {
|
||||
return uuid.Nil, errs.WrapCode(err, errs.Internal, "invalid user id")
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
// isForeignKeyViolation reports whether err is a Postgres foreign key constraint violation.
|
||||
func isForeignKeyViolation(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
return errors.As(err, &pgErr) && pgErr.Code == "23503"
|
||||
}
|
||||
168
profiles/profiles.go
Normal file
@@ -0,0 +1,168 @@
|
||||
// Service profiles stores and serves user profile information.
|
||||
package profiles
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
authsvc "encore.app/auth"
|
||||
"encore.dev/beta/auth"
|
||||
"encore.dev/beta/errs"
|
||||
"encore.dev/storage/sqldb"
|
||||
"encore.dev/types/uuid"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
var db = sqldb.NewDatabase("profiles", sqldb.DatabaseConfig{
|
||||
Migrations: "./migrations",
|
||||
})
|
||||
|
||||
type Profile struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display_name"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
IsArtist bool `json:"is_artist"`
|
||||
Role string `json:"role"`
|
||||
Status Status `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ProfileParams struct {
|
||||
DisplayName string `json:"display_name"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
}
|
||||
|
||||
type RegisterParams struct {
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display_name"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
Password string `json:"password" encore:"sensitive"`
|
||||
}
|
||||
|
||||
// Insert registers a new profile with a server-generated UUID v4, delegating
|
||||
// credential setup to the auth service.
|
||||
//
|
||||
//encore:api public method=POST path=/profiles
|
||||
func Insert(ctx context.Context, p *RegisterParams) (*Profile, error) {
|
||||
userID, err := uuid.NewV4()
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "failed to generate profile id")
|
||||
}
|
||||
profile := Profile{UserID: userID, Email: p.Email, DisplayName: p.DisplayName, AvatarURL: p.AvatarURL}
|
||||
err = db.QueryRow(ctx, `
|
||||
INSERT INTO user_profiles (user_id, email, display_name, avatar_url)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING role, status, is_artist, created_at, updated_at
|
||||
`, userID, p.Email, p.DisplayName, p.AvatarURL).Scan(&profile.Role, &profile.Status, &profile.IsArtist, &profile.CreatedAt, &profile.UpdatedAt)
|
||||
if isUniqueViolation(err) {
|
||||
return nil, &errs.Error{Code: errs.AlreadyExists, Message: "email already registered"}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "failed to create profile")
|
||||
}
|
||||
if err := authsvc.Register(ctx, &authsvc.RegisterParams{UserID: userID, Password: p.Password}); err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "failed to register credentials")
|
||||
}
|
||||
return &profile, nil
|
||||
}
|
||||
|
||||
// Update replaces the data of the authenticated user's own profile.
|
||||
//
|
||||
//encore:api auth method=PUT path=/profiles
|
||||
func Update(ctx context.Context, p *ProfileParams) (*Profile, error) {
|
||||
uid, ok := auth.UserID()
|
||||
if !ok {
|
||||
return nil, &errs.Error{Code: errs.PermissionDenied, Message: "cannot act on another user's profile"}
|
||||
}
|
||||
userID, err := uuid.FromString(string(uid))
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "invalid user id")
|
||||
}
|
||||
if err := requireSelf(userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profile := Profile{UserID: userID, DisplayName: p.DisplayName, AvatarURL: p.AvatarURL}
|
||||
err = db.QueryRow(ctx, `
|
||||
UPDATE user_profiles
|
||||
SET display_name = $2, avatar_url = $3, updated_at = NOW()
|
||||
WHERE user_id = $1
|
||||
RETURNING email, role, status, is_artist, created_at, updated_at
|
||||
`, userID, p.DisplayName, p.AvatarURL).Scan(&profile.Email, &profile.Role, &profile.Status, &profile.IsArtist, &profile.CreatedAt, &profile.UpdatedAt)
|
||||
if errors.Is(err, sqldb.ErrNoRows) {
|
||||
return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "failed to update profile")
|
||||
}
|
||||
return &profile, nil
|
||||
}
|
||||
|
||||
// Get returns the profile for the given user. For the caller's own profile
|
||||
// (including when not authenticated), see MyProfile.
|
||||
//
|
||||
//encore:api public method=GET path=/profiles/profile
|
||||
func Get(ctx context.Context) (*Profile, error) {
|
||||
uid, ok := auth.UserID()
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
userID, err := uuid.FromString(string(uid))
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "invalid user id")
|
||||
}
|
||||
p := Profile{UserID: userID}
|
||||
err = db.QueryRow(ctx, `
|
||||
SELECT email, display_name, avatar_url, role, status, is_artist, created_at, updated_at FROM user_profiles WHERE user_id = $1
|
||||
`, userID).Scan(&p.Email, &p.DisplayName, &p.AvatarURL, &p.Role, &p.Status, &p.IsArtist, &p.CreatedAt, &p.UpdatedAt)
|
||||
if errors.Is(err, sqldb.ErrNoRows) {
|
||||
return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "failed to fetch profile")
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// Delete removes the authenticated user's own profile.
|
||||
//
|
||||
//encore:api auth method=DELETE path=/profiles
|
||||
func Delete(ctx context.Context) error {
|
||||
uid, ok := auth.UserID()
|
||||
if !ok {
|
||||
return &errs.Error{Code: errs.PermissionDenied, Message: "cannot act on another user's profile"}
|
||||
}
|
||||
userID, err := uuid.FromString(string(uid))
|
||||
if err != nil {
|
||||
return errs.WrapCode(err, errs.Internal, "invalid user id")
|
||||
}
|
||||
if err := requireSelf(userID); err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := db.Exec(ctx, `
|
||||
DELETE FROM user_profiles WHERE user_id = $1
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return errs.WrapCode(err, errs.Internal, "failed to delete profile")
|
||||
}
|
||||
if res.RowsAffected() == 0 {
|
||||
return &errs.Error{Code: errs.NotFound, Message: "profile not found"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// requireSelf returns an error unless the authenticated caller is the user identified by userID.
|
||||
func requireSelf(userID uuid.UUID) error {
|
||||
if uid, ok := auth.UserID(); !ok || uid != auth.UID(userID.String()) {
|
||||
return &errs.Error{Code: errs.PermissionDenied, Message: "cannot act on another user's profile"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isUniqueViolation reports whether err is a Postgres unique constraint violation.
|
||||
func isUniqueViolation(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
return errors.As(err, &pgErr) && pgErr.Code == "23505"
|
||||
}
|
||||
38
profiles/status.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package profiles
|
||||
|
||||
type Status int
|
||||
|
||||
const (
|
||||
StatusActive Status = iota
|
||||
StatusInactive
|
||||
StatusSuspended
|
||||
StatusDeleted
|
||||
StatusPending
|
||||
StatusWaitingDeletion
|
||||
StatusBanned
|
||||
)
|
||||
|
||||
// StatusOption describes a valid profile status for display in admin UIs.
|
||||
type StatusOption struct {
|
||||
Name string `json:"name"`
|
||||
Value Status `json:"value"`
|
||||
Updatable bool `json:"updatable"`
|
||||
}
|
||||
|
||||
// Statuses returns every valid profile status with a display name and its underlying value.
|
||||
func Statuses() []StatusOption {
|
||||
return []StatusOption{
|
||||
{Name: "Active", Value: StatusActive, Updatable: true},
|
||||
{Name: "Inactive", Value: StatusInactive, Updatable: true},
|
||||
{Name: "Suspended", Value: StatusSuspended, Updatable: true},
|
||||
{Name: "Deleted", Value: StatusDeleted, Updatable: false},
|
||||
{Name: "Pending", Value: StatusPending, Updatable: true},
|
||||
{Name: "Waiting deletion", Value: StatusWaitingDeletion, Updatable: false},
|
||||
{Name: "Banned", Value: StatusBanned, Updatable: true},
|
||||
}
|
||||
}
|
||||
|
||||
// IsValid reports whether s is one of the defined Status values.
|
||||
func (s Status) IsValid() bool {
|
||||
return s >= StatusActive && s <= StatusBanned
|
||||
}
|
||||