Compare commits
2 Commits
591a6181cf
...
167be9b0b3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
# Encore
|
||||||
# If you prefer the allow list template instead of the deny list, see community template:
|
/.encore
|
||||||
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
encore.gen.go
|
||||||
#
|
encore.gen.cue
|
||||||
# Binaries for programs and plugins
|
/encore.gen
|
||||||
|
.secrets.local.cue
|
||||||
|
.secrets.local.cue
|
||||||
|
|
||||||
|
# Go
|
||||||
*.exe
|
*.exe
|
||||||
*.exe~
|
*.exe~
|
||||||
*.dll
|
*.dll
|
||||||
*.so
|
*.so
|
||||||
*.dylib
|
*.dylib
|
||||||
|
|
||||||
# Test binary, built with `go test -c`
|
|
||||||
*.test
|
*.test
|
||||||
|
|
||||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
|
||||||
*.out
|
*.out
|
||||||
|
|
||||||
# Dependency directories (remove the comment below to include it)
|
|
||||||
# vendor/
|
|
||||||
|
|
||||||
# Go workspace file
|
|
||||||
go.work
|
go.work
|
||||||
go.work.sum
|
go.work.sum
|
||||||
|
|
||||||
# env file
|
# Environment files
|
||||||
.env
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
# ---> Node
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
@@ -35,7 +33,7 @@ yarn-error.log*
|
|||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
.pnpm-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
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
# Runtime data
|
# Runtime data
|
||||||
@@ -44,122 +42,52 @@ pids
|
|||||||
*.seed
|
*.seed
|
||||||
*.pid.lock
|
*.pid.lock
|
||||||
|
|
||||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
# Coverage and build output
|
||||||
lib-cov
|
|
||||||
|
|
||||||
# Coverage directory used by tools like istanbul
|
|
||||||
coverage
|
coverage
|
||||||
*.lcov
|
*.lcov
|
||||||
|
|
||||||
# nyc test coverage
|
|
||||||
.nyc_output
|
.nyc_output
|
||||||
|
dist
|
||||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
out
|
||||||
.grunt
|
|
||||||
|
|
||||||
# Bower dependency directory (https://bower.io/)
|
|
||||||
bower_components
|
|
||||||
|
|
||||||
# node-waf configuration
|
|
||||||
.lock-wscript
|
|
||||||
|
|
||||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
|
||||||
build/Release
|
build/Release
|
||||||
|
|
||||||
# Dependency directories
|
# Dependency directories
|
||||||
node_modules/
|
node_modules/
|
||||||
jspm_packages/
|
jspm_packages/
|
||||||
|
bower_components/
|
||||||
# Snowpack dependency directory (https://snowpack.dev/)
|
|
||||||
web_modules/
|
web_modules/
|
||||||
|
|
||||||
# TypeScript cache
|
# Tool caches
|
||||||
*.tsbuildinfo
|
lib-cov
|
||||||
|
.grunt
|
||||||
# Optional npm cache directory
|
|
||||||
.npm
|
.npm
|
||||||
|
|
||||||
# Optional eslint cache
|
|
||||||
.eslintcache
|
.eslintcache
|
||||||
|
|
||||||
# Optional stylelint cache
|
|
||||||
.stylelintcache
|
.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
|
.cache
|
||||||
.parcel-cache
|
|
||||||
|
|
||||||
# Next.js build output
|
|
||||||
.next
|
|
||||||
out
|
|
||||||
|
|
||||||
# Nuxt.js build / generate output
|
|
||||||
.nuxt
|
|
||||||
dist
|
|
||||||
|
|
||||||
# Gatsby files
|
|
||||||
.cache/
|
.cache/
|
||||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
.parcel-cache
|
||||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
.next
|
||||||
# public
|
.nuxt
|
||||||
|
|
||||||
# vuepress build output
|
|
||||||
.vuepress/dist
|
|
||||||
|
|
||||||
# vuepress v2.x temp and cache directory
|
|
||||||
.temp
|
.temp
|
||||||
.cache
|
.vuepress/dist
|
||||||
|
|
||||||
# vitepress build output
|
|
||||||
**/.vitepress/dist
|
**/.vitepress/dist
|
||||||
|
|
||||||
# vitepress cache directory
|
|
||||||
**/.vitepress/cache
|
**/.vitepress/cache
|
||||||
|
|
||||||
# Docusaurus cache and generated files
|
|
||||||
.docusaurus
|
.docusaurus
|
||||||
|
|
||||||
# Serverless directories
|
|
||||||
.serverless/
|
.serverless/
|
||||||
|
|
||||||
# FuseBox cache
|
|
||||||
.fusebox/
|
.fusebox/
|
||||||
|
|
||||||
# DynamoDB Local files
|
|
||||||
.dynamodb/
|
.dynamodb/
|
||||||
|
|
||||||
# TernJS port file
|
|
||||||
.tern-port
|
.tern-port
|
||||||
|
|
||||||
# Stores VSCode versions used for testing VSCode extensions
|
|
||||||
.vscode-test
|
.vscode-test
|
||||||
|
|
||||||
# yarn v2
|
# TypeScript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Packaging
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn
|
||||||
|
.yarn-integrity
|
||||||
.yarn/cache
|
.yarn/cache
|
||||||
.yarn/unplugged
|
.yarn/unplugged
|
||||||
.yarn/build-state.yml
|
.yarn/build-state.yml
|
||||||
.yarn/install-state.gz
|
.yarn/install-state.gz
|
||||||
.pnp.*
|
.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
|
# 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.
|
||||||
|
|||||||
321
admin/admin.go
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
// 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 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"`
|
||||||
|
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"`
|
||||||
|
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, city, country, created_at, updated_at
|
||||||
|
FROM personal_data WHERE user_id = $1
|
||||||
|
`, userID).Scan(&pd.FirstName, &pd.LastName, &pd.Address, &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,
|
||||||
|
City: p.City,
|
||||||
|
Country: p.Country,
|
||||||
|
}
|
||||||
|
err := profilesDB.QueryRow(ctx, `
|
||||||
|
INSERT INTO personal_data (user_id, first_name, last_name, address, city, country)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
ON CONFLICT (user_id) DO UPDATE
|
||||||
|
SET first_name = EXCLUDED.first_name,
|
||||||
|
last_name = EXCLUDED.last_name,
|
||||||
|
address = EXCLUDED.address,
|
||||||
|
city = EXCLUDED.city,
|
||||||
|
country = EXCLUDED.country,
|
||||||
|
updated_at = NOW()
|
||||||
|
RETURNING created_at, updated_at
|
||||||
|
`, userID, p.FirstName, p.LastName, p.Address, 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
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
62
auth/credentials.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"encore.dev/beta/auth"
|
||||||
|
"encore.dev/beta/errs"
|
||||||
|
"encore.dev/types/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
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: []
|
||||||
|
},
|
||||||
|
|
||||||
|
// 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>
|
||||||
76
frontend/src/components/DrawerUserCard.vue
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<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')"
|
||||||
|
to="/profile"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<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('/');
|
||||||
|
}
|
||||||
|
</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>
|
||||||
1
frontend/src/css/app.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/* app global css */
|
||||||
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'},
|
||||||
|
]
|
||||||
1365
frontend/src/encore/client.ts
Executable file
71
frontend/src/encore/zod.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
// 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),
|
||||||
|
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 AdminUpdateProfileRoleParamsSchema = z.object({
|
||||||
|
role: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AdminUpdateProfileStatusParamsSchema = z.object({
|
||||||
|
status: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AuthSetPasswordParamsSchema = z.object({
|
||||||
|
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(),
|
||||||
|
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),
|
||||||
|
});
|
||||||
558
frontend/src/i18n/index.ts
Normal file
@@ -0,0 +1,558 @@
|
|||||||
|
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',
|
||||||
|
},
|
||||||
|
fields: {
|
||||||
|
email: 'Email',
|
||||||
|
password: 'Password',
|
||||||
|
displayName: 'Display name',
|
||||||
|
firstName: 'First name',
|
||||||
|
lastName: 'Last name',
|
||||||
|
address: 'Address',
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
profile: {
|
||||||
|
title: 'My profile',
|
||||||
|
loginRequired: 'You must be logged in to view your profile.',
|
||||||
|
},
|
||||||
|
login: {
|
||||||
|
title: 'Log in',
|
||||||
|
needAccount: 'Create an account',
|
||||||
|
},
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
fields: {
|
||||||
|
email: 'Email',
|
||||||
|
password: 'Password',
|
||||||
|
displayName: 'Nome visualizzato',
|
||||||
|
firstName: 'Nome',
|
||||||
|
lastName: 'Cognome',
|
||||||
|
address: 'Indirizzo',
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
profile: {
|
||||||
|
title: 'Il mio profilo',
|
||||||
|
loginRequired: 'Devi effettuare l’accesso per visualizzare il profilo.',
|
||||||
|
},
|
||||||
|
login: {
|
||||||
|
title: 'Accedi',
|
||||||
|
needAccount: 'Crea un account',
|
||||||
|
},
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
fields: {
|
||||||
|
email: 'Email',
|
||||||
|
password: 'Mot de passe',
|
||||||
|
displayName: 'Nom affiché',
|
||||||
|
firstName: 'Prénom',
|
||||||
|
lastName: 'Nom',
|
||||||
|
address: 'Adresse',
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
profile: {
|
||||||
|
title: 'Mon profil',
|
||||||
|
loginRequired: 'Vous devez être connecté pour voir votre profil.',
|
||||||
|
},
|
||||||
|
login: {
|
||||||
|
title: 'Connexion',
|
||||||
|
needAccount: 'Créer un compte',
|
||||||
|
},
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
fields: {
|
||||||
|
email: 'E-Mail',
|
||||||
|
password: 'Passwort',
|
||||||
|
displayName: 'Anzeigename',
|
||||||
|
firstName: 'Vorname',
|
||||||
|
lastName: 'Nachname',
|
||||||
|
address: 'Adresse',
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
profile: {
|
||||||
|
title: 'Mein Profil',
|
||||||
|
loginRequired: 'Sie müssen angemeldet sein, um Ihr Profil zu sehen.',
|
||||||
|
},
|
||||||
|
login: {
|
||||||
|
title: 'Anmelden',
|
||||||
|
needAccount: 'Konto erstellen',
|
||||||
|
},
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
fields: {
|
||||||
|
email: 'Email',
|
||||||
|
password: 'Contraseña',
|
||||||
|
displayName: 'Nombre visible',
|
||||||
|
firstName: 'Nombre',
|
||||||
|
lastName: 'Apellido',
|
||||||
|
address: 'Dirección',
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
profile: {
|
||||||
|
title: 'Mi perfil',
|
||||||
|
loginRequired: 'Debes iniciar sesión para ver tu perfil.',
|
||||||
|
},
|
||||||
|
login: {
|
||||||
|
title: 'Iniciar sesión',
|
||||||
|
needAccount: 'Crear una cuenta',
|
||||||
|
},
|
||||||
|
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>
|
||||||
78
frontend/src/pages/LoginPage.vue
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<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"
|
||||||
|
:rules="[(val) => !!val || t('fields.email')]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<q-input
|
||||||
|
v-model="password"
|
||||||
|
:label="t('fields.password')"
|
||||||
|
type="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
:rules="[(val) => !!val || t('fields.password')]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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 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>
|
||||||
69
frontend/src/pages/ProfilePage.vue
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<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: 480px">
|
||||||
|
<q-card-section class="q-gutter-md">
|
||||||
|
<AvatarUpload v-model="form.avatar_url" :uploader="profilesStore.uploadAvatar" />
|
||||||
|
|
||||||
|
<q-input v-model="form.display_name" :label="t('fields.displayName')" />
|
||||||
|
|
||||||
|
<div class="text-caption text-grey">
|
||||||
|
{{ profilesStore.profile.email }} · {{ profilesStore.profile.role }}
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-actions align="right">
|
||||||
|
<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 { onMounted, reactive, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useProfilesStore, type Profile } from '@/stores/profiles-store';
|
||||||
|
import AvatarUpload from '@/components/AvatarUpload.vue';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const profilesStore = useProfilesStore();
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
display_name: '',
|
||||||
|
avatar_url: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
function syncForm(profile: Profile | null) {
|
||||||
|
form.display_name = profile?.display_name ?? '';
|
||||||
|
form.avatar_url = profile?.avatar_url ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => profilesStore.profile, syncForm, { immediate: true });
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
try {
|
||||||
|
await profilesStore.update({ ...form });
|
||||||
|
} catch {
|
||||||
|
// profilesStore.error already holds the failure message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
151
frontend/src/pages/admin/ProfilesPage.vue
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<template>
|
||||||
|
<q-page class="q-pa-md">
|
||||||
|
<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
|
||||||
|
:rows="adminStore.profiles"
|
||||||
|
:columns="columns"
|
||||||
|
row-key="user_id"
|
||||||
|
:loading="adminStore.loading"
|
||||||
|
flat
|
||||||
|
bordered
|
||||||
|
>
|
||||||
|
<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-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" />
|
||||||
|
<CreateProfileDialog v-model="createDialogOpen" />
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import type { QTableColumn } from 'quasar';
|
||||||
|
import { useAdminStore, type Profile } from '@/stores/admin-store';
|
||||||
|
import { statusInfo } from '@/stores/profiles-store';
|
||||||
|
import CreateProfileDialog from './dialogs/CreateProfileDialog.vue';
|
||||||
|
import EditProfileDialog from './dialogs/EditProfileDialog.vue';
|
||||||
|
import UpdateRoleDialog from './dialogs/UpdateRoleDialog.vue';
|
||||||
|
import UpdateStatusDialog from './dialogs/UpdateStatusDialog.vue';
|
||||||
|
|
||||||
|
const adminStore = useAdminStore();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
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 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void adminStore.listProfiles();
|
||||||
|
void adminStore.fetchSystemOptions();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
86
frontend/src/pages/admin/dialogs/CreateProfileDialog.vue
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<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 { 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 });
|
||||||
|
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>
|
||||||
249
frontend/src/pages/admin/dialogs/EditProfileDialog.vue
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
<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.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 { 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: '',
|
||||||
|
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);
|
||||||
|
} else {
|
||||||
|
const parsed = validatePersonalForm();
|
||||||
|
if (!parsed) return;
|
||||||
|
await adminStore.upsertPersonalData(props.profile.user_id, parsed);
|
||||||
|
}
|
||||||
|
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.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.city = '';
|
||||||
|
personalFormErrors.country = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function focusFirstField() {
|
||||||
|
await nextTick();
|
||||||
|
if (editTab.value === 'profile') {
|
||||||
|
displayNameInputRef.value?.focus();
|
||||||
|
} else {
|
||||||
|
firstNameInputRef.value?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
76
frontend/src/pages/admin/dialogs/UpdateRoleDialog.vue
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<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 { 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);
|
||||||
|
open.value = false;
|
||||||
|
} catch {
|
||||||
|
// adminStore.error already holds the failure message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function focusFirstField() {
|
||||||
|
await nextTick();
|
||||||
|
roleSelectRef.value?.focus();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
77
frontend/src/pages/admin/dialogs/UpdateStatusDialog.vue
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<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 { 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);
|
||||||
|
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;
|
||||||
|
});
|
||||||
33
frontend/src/router/routes.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
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: '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;
|
||||||
150
frontend/src/stores/admin-store.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
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 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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,
|
||||||
|
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;
|
||||||
|
});
|
||||||
167
frontend/src/stores/profiles-store.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { LocalStorage } from 'quasar';
|
||||||
|
import Client, { Local, 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;
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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,
|
||||||
|
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)
|
||||||
|
}
|
||||||
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()
|
||||||
|
);
|
||||||
112
profiles/personal_data.go
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
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"`
|
||||||
|
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"`
|
||||||
|
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, city, country, created_at, updated_at
|
||||||
|
FROM personal_data WHERE user_id = $1
|
||||||
|
`, userID).Scan(&pd.FirstName, &pd.LastName, &pd.Address, &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,
|
||||||
|
City: p.City,
|
||||||
|
Country: p.Country,
|
||||||
|
}
|
||||||
|
err = db.QueryRow(ctx, `
|
||||||
|
INSERT INTO personal_data (user_id, first_name, last_name, address, city, country)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
ON CONFLICT (user_id) DO UPDATE
|
||||||
|
SET first_name = EXCLUDED.first_name,
|
||||||
|
last_name = EXCLUDED.last_name,
|
||||||
|
address = EXCLUDED.address,
|
||||||
|
city = EXCLUDED.city,
|
||||||
|
country = EXCLUDED.country,
|
||||||
|
updated_at = NOW()
|
||||||
|
RETURNING created_at, updated_at
|
||||||
|
`, userID, p.FirstName, p.LastName, p.Address, 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
|
||||||
|
}
|
||||||
12
registration/mails/welcome_html.tmpl
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<p>Hello {{.Name}},</p>
|
||||||
|
<p>Your account has been created successfully.</p>
|
||||||
|
<p>
|
||||||
|
Confirm your email address by opening this link:
|
||||||
|
<br />
|
||||||
|
<a href="{{.WelcomeURL}}">{{.WelcomeURL}}</a>
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1
registration/mails/welcome_subject.tmpl
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Welcome to Encore
|
||||||
6
registration/mails/welcome_text.tmpl
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
Hello {{.Name}},
|
||||||
|
|
||||||
|
Your account has been created successfully.
|
||||||
|
|
||||||
|
Confirm your email address by opening this link:
|
||||||
|
{{.WelcomeURL}}
|
||||||
11
registration/migrations/1_create_welcome_tokens.up.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE welcome_tokens (
|
||||||
|
token UUID PRIMARY KEY,
|
||||||
|
user_id UUID NOT NULL,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
used_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX welcome_tokens_user_id_idx ON welcome_tokens (user_id);
|
||||||
|
CREATE INDEX welcome_tokens_email_idx ON welcome_tokens (email);
|
||||||
259
registration/registration.go
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
// Service registration orchestrates new user registration.
|
||||||
|
package registration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"embed"
|
||||||
|
"errors"
|
||||||
|
htmltemplate "html/template"
|
||||||
|
"net"
|
||||||
|
"net/mail"
|
||||||
|
"strings"
|
||||||
|
texttemplate "text/template"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
mailssvc "encore.app/mails"
|
||||||
|
profilessvc "encore.app/profiles"
|
||||||
|
"encore.dev/beta/errs"
|
||||||
|
"encore.dev/storage/sqldb"
|
||||||
|
"encore.dev/types/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed mails/*.tmpl
|
||||||
|
var mailTemplates embed.FS
|
||||||
|
|
||||||
|
var db = sqldb.NewDatabase("registration", sqldb.DatabaseConfig{
|
||||||
|
Migrations: "./migrations",
|
||||||
|
})
|
||||||
|
|
||||||
|
var profilesDB = sqldb.Named("profiles")
|
||||||
|
|
||||||
|
const (
|
||||||
|
welcomeTokenTTL = 24 * time.Hour
|
||||||
|
frontendBaseURL = "http://localhost:9000/#"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RegisterParams struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
AvatarURL string `json:"avatar_url"`
|
||||||
|
Password string `json:"password" encore:"sensitive"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RegisterResponse struct {
|
||||||
|
Profile *profilessvc.Profile `json:"profile"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WelcomeResponse struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Confirmed bool `json:"confirmed"`
|
||||||
|
UsedAt time.Time `json:"used_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmailAvailabilityResponse struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Available bool `json:"available"`
|
||||||
|
MXValid bool `json:"mx_valid"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckEmail reports whether an email can be used for a new registration.
|
||||||
|
//
|
||||||
|
//encore:api public method=GET path=/registration/email/:email
|
||||||
|
func CheckEmail(ctx context.Context, email string) (*EmailAvailabilityResponse, error) {
|
||||||
|
email = strings.TrimSpace(email)
|
||||||
|
if email == "" {
|
||||||
|
return nil, &errs.Error{Code: errs.InvalidArgument, Message: "email is required"}
|
||||||
|
}
|
||||||
|
parsed, err := mail.ParseAddress(email)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &errs.Error{Code: errs.InvalidArgument, Message: "invalid email"}
|
||||||
|
}
|
||||||
|
email = parsed.Address
|
||||||
|
|
||||||
|
domain, ok := emailDomain(email)
|
||||||
|
if !ok {
|
||||||
|
return nil, &errs.Error{Code: errs.InvalidArgument, Message: "invalid email domain"}
|
||||||
|
}
|
||||||
|
mxValid, err := hasValidMX(domain)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.WrapCode(err, errs.Internal, "failed to check email domain")
|
||||||
|
}
|
||||||
|
|
||||||
|
var exists bool
|
||||||
|
err = profilesDB.QueryRow(ctx, `
|
||||||
|
SELECT EXISTS(SELECT 1 FROM user_profiles WHERE email = $1)
|
||||||
|
`, email).Scan(&exists)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.WrapCode(err, errs.Internal, "failed to check email")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &EmailAvailabilityResponse{Email: email, Available: !exists, MXValid: mxValid}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register creates a new user and records the welcome transactional email.
|
||||||
|
//
|
||||||
|
//encore:api public method=POST path=/registration
|
||||||
|
func Register(ctx context.Context, p *RegisterParams) (*RegisterResponse, error) {
|
||||||
|
profile, err := profilessvc.Insert(ctx, &profilessvc.RegisterParams{
|
||||||
|
Email: p.Email,
|
||||||
|
DisplayName: p.DisplayName,
|
||||||
|
AvatarURL: p.AvatarURL,
|
||||||
|
Password: p.Password,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := createWelcomeToken(ctx, profile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.WrapCode(err, errs.Internal, "failed to create welcome token")
|
||||||
|
}
|
||||||
|
|
||||||
|
mail, err := welcomeMail(profile, token)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.WrapCode(err, errs.Internal, "failed to render welcome email")
|
||||||
|
}
|
||||||
|
if _, err := mailssvc.SendTransactional(ctx, mail); err != nil {
|
||||||
|
return nil, errs.WrapCode(err, errs.Internal, "failed to queue welcome email")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &RegisterResponse{Profile: profile}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfirmWelcome validates the welcome email token.
|
||||||
|
//
|
||||||
|
//encore:api public method=POST path=/registration/welcome/:token
|
||||||
|
func ConfirmWelcome(ctx context.Context, token uuid.UUID) (*WelcomeResponse, error) {
|
||||||
|
var email string
|
||||||
|
var expiresAt time.Time
|
||||||
|
var usedAt *time.Time
|
||||||
|
err := db.QueryRow(ctx, `
|
||||||
|
SELECT email, expires_at, used_at FROM welcome_tokens WHERE token = $1
|
||||||
|
`, token).Scan(&email, &expiresAt, &usedAt)
|
||||||
|
if errors.Is(err, sqldb.ErrNoRows) {
|
||||||
|
return nil, &errs.Error{Code: errs.NotFound, Message: "welcome token not found"}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.WrapCode(err, errs.Internal, "failed to load welcome token")
|
||||||
|
}
|
||||||
|
if usedAt != nil {
|
||||||
|
return &WelcomeResponse{Email: email, Confirmed: true, UsedAt: *usedAt}, nil
|
||||||
|
}
|
||||||
|
if time.Now().After(expiresAt) {
|
||||||
|
return nil, &errs.Error{Code: errs.InvalidArgument, Message: "welcome token has expired"}
|
||||||
|
}
|
||||||
|
|
||||||
|
var confirmedAt time.Time
|
||||||
|
err = db.QueryRow(ctx, `
|
||||||
|
UPDATE welcome_tokens SET used_at = NOW() WHERE token = $1
|
||||||
|
RETURNING used_at
|
||||||
|
`, token).Scan(&confirmedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.WrapCode(err, errs.Internal, "failed to confirm welcome token")
|
||||||
|
}
|
||||||
|
return &WelcomeResponse{Email: email, Confirmed: true, UsedAt: confirmedAt}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createWelcomeToken(ctx context.Context, profile *profilessvc.Profile) (uuid.UUID, error) {
|
||||||
|
token, err := uuid.NewV4()
|
||||||
|
if err != nil {
|
||||||
|
return uuid.Nil, err
|
||||||
|
}
|
||||||
|
_, err = db.Exec(ctx, `
|
||||||
|
INSERT INTO welcome_tokens (token, user_id, email, expires_at)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
`, token, profile.UserID, profile.Email, time.Now().Add(welcomeTokenTTL))
|
||||||
|
return token, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func emailDomain(email string) (string, bool) {
|
||||||
|
_, domain, ok := strings.Cut(email, "@")
|
||||||
|
domain = strings.TrimSpace(strings.TrimSuffix(domain, "."))
|
||||||
|
return domain, ok && domain != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasValidMX(domain string) (bool, error) {
|
||||||
|
records, err := net.LookupMX(domain)
|
||||||
|
if err != nil {
|
||||||
|
if dnsErr, ok := err.(*net.DNSError); ok && dnsErr.IsNotFound {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
for _, record := range records {
|
||||||
|
if strings.TrimSpace(record.Host) != "." {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func welcomeMail(profile *profilessvc.Profile, token uuid.UUID) (*mailssvc.TransactionalMailParams, error) {
|
||||||
|
name := profile.DisplayName
|
||||||
|
if name == "" {
|
||||||
|
name = profile.Email
|
||||||
|
}
|
||||||
|
|
||||||
|
data := welcomeMailData{
|
||||||
|
Name: name,
|
||||||
|
Email: profile.Email,
|
||||||
|
UserID: profile.UserID.String(),
|
||||||
|
WelcomeURL: frontendBaseURL + "/welcome?token=" + token.String(),
|
||||||
|
}
|
||||||
|
|
||||||
|
subject, err := renderTextTemplate("mails/welcome_subject.tmpl", data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
textBody, err := renderTextTemplate("mails/welcome_text.tmpl", data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
htmlBody, err := renderHTMLTemplate("mails/welcome_html.tmpl", data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &mailssvc.TransactionalMailParams{
|
||||||
|
To: profile.Email,
|
||||||
|
Subject: subject,
|
||||||
|
TextBody: textBody,
|
||||||
|
HTMLBody: htmlBody,
|
||||||
|
Metadata: map[string]string{
|
||||||
|
"kind": "welcome",
|
||||||
|
"user_id": profile.UserID.String(),
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type welcomeMailData struct {
|
||||||
|
Name string
|
||||||
|
Email string
|
||||||
|
UserID string
|
||||||
|
WelcomeURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderTextTemplate(name string, data welcomeMailData) (string, error) {
|
||||||
|
tmpl, err := texttemplate.ParseFS(mailTemplates, name)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := tmpl.Execute(&out, data); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(out.String()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderHTMLTemplate(name string, data welcomeMailData) (string, error) {
|
||||||
|
tmpl, err := htmltemplate.ParseFS(mailTemplates, name)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := tmpl.Execute(&out, data); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(out.String()), nil
|
||||||
|
}
|
||||||