mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-12 08:45:38 +02:00
⭐ feat(www): Finish up on the onboarding (#210)
Merging this prematurely to make sure the team is on the same boat... like dang! We need to find a better way to do this. Plus it has become too big
This commit is contained in:
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"typescript.tsdk": "node_modules/typescript/lib"
|
||||||
|
}
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
"@nestri/libmoq": "*",
|
"@nestri/libmoq": "*",
|
||||||
"@nestri/sdk": "0.1.0-alpha.14",
|
"@nestri/sdk": "0.1.0-alpha.14",
|
||||||
"@nestri/ui": "*",
|
"@nestri/ui": "*",
|
||||||
"@openauthjs/openauth": "^0.2.6",
|
"@openauthjs/openauth": "*",
|
||||||
"@polar-sh/checkout": "^0.1.8",
|
"@polar-sh/checkout": "^0.1.8",
|
||||||
"@polar-sh/sdk": "^0.21.1",
|
"@polar-sh/sdk": "^0.21.1",
|
||||||
"@qwik-ui/headless": "^0.6.4",
|
"@qwik-ui/headless": "^0.6.4",
|
||||||
|
|||||||
45
infra/api.ts
45
infra/api.ts
@@ -1,8 +1,8 @@
|
|||||||
|
import { vpc } from "./vpc";
|
||||||
import { bus } from "./bus";
|
import { bus } from "./bus";
|
||||||
import { domain } from "./dns";
|
import { domain } from "./dns";
|
||||||
import { email } from "./email";
|
|
||||||
import { secret } from "./secret";
|
import { secret } from "./secret";
|
||||||
import { database } from "./database";
|
import { postgres } from "./postgres";
|
||||||
|
|
||||||
sst.Linkable.wrap(random.RandomString, (resource) => ({
|
sst.Linkable.wrap(random.RandomString, (resource) => ({
|
||||||
properties: {
|
properties: {
|
||||||
@@ -14,51 +14,17 @@ export const urls = new sst.Linkable("Urls", {
|
|||||||
properties: {
|
properties: {
|
||||||
api: "https://api." + domain,
|
api: "https://api." + domain,
|
||||||
auth: "https://auth." + domain,
|
auth: "https://auth." + domain,
|
||||||
site: $dev ? "http://localhost:4321" : "https://" + domain,
|
site: $dev ? "http://localhost:3000" : "https://" + domain,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const authFingerprintKey = new random.RandomString(
|
|
||||||
"AuthFingerprintKey",
|
|
||||||
{
|
|
||||||
length: 32,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const auth = new sst.aws.Auth("Auth", {
|
|
||||||
issuer: {
|
|
||||||
timeout: "3 minutes",
|
|
||||||
handler: "./packages/functions/src/auth.handler",
|
|
||||||
link: [
|
|
||||||
bus,
|
|
||||||
email,
|
|
||||||
database,
|
|
||||||
authFingerprintKey,
|
|
||||||
secret.PolarSecret,
|
|
||||||
secret.GithubClientID,
|
|
||||||
secret.DiscordClientID,
|
|
||||||
secret.GithubClientSecret,
|
|
||||||
secret.DiscordClientSecret,
|
|
||||||
],
|
|
||||||
permissions: [
|
|
||||||
{
|
|
||||||
actions: ["ses:SendEmail"],
|
|
||||||
resources: ["*"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
domain: {
|
|
||||||
name: "auth." + domain,
|
|
||||||
dns: sst.cloudflare.dns(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export const apiFunction = new sst.aws.Function("ApiFn", {
|
export const apiFunction = new sst.aws.Function("ApiFn", {
|
||||||
|
vpc,
|
||||||
handler: "packages/functions/src/api/index.handler",
|
handler: "packages/functions/src/api/index.handler",
|
||||||
link: [
|
link: [
|
||||||
bus,
|
bus,
|
||||||
urls,
|
urls,
|
||||||
database,
|
postgres,
|
||||||
secret.PolarSecret,
|
secret.PolarSecret,
|
||||||
],
|
],
|
||||||
timeout: "3 minutes",
|
timeout: "3 minutes",
|
||||||
@@ -77,6 +43,5 @@ export const api = new sst.aws.Router("Api", {
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const outputs = {
|
export const outputs = {
|
||||||
auth: auth.url,
|
|
||||||
api: api.url,
|
api: api.url,
|
||||||
};
|
};
|
||||||
46
infra/auth.ts
Normal file
46
infra/auth.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { vpc } from "./vpc";
|
||||||
|
import { bus } from "./bus";
|
||||||
|
import { domain } from "./dns";
|
||||||
|
import { email } from "./email";
|
||||||
|
import { secret } from "./secret";
|
||||||
|
import { postgres } from "./postgres";
|
||||||
|
|
||||||
|
export const authFingerprintKey = new random.RandomString(
|
||||||
|
"AuthFingerprintKey",
|
||||||
|
{
|
||||||
|
length: 32,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const auth = new sst.aws.Auth("Auth", {
|
||||||
|
issuer: {
|
||||||
|
vpc,
|
||||||
|
timeout: "3 minutes",
|
||||||
|
handler: "packages/functions/src/auth.handler",
|
||||||
|
link: [
|
||||||
|
bus,
|
||||||
|
email,
|
||||||
|
postgres,
|
||||||
|
authFingerprintKey,
|
||||||
|
secret.PolarSecret,
|
||||||
|
secret.GithubClientID,
|
||||||
|
secret.DiscordClientID,
|
||||||
|
secret.GithubClientSecret,
|
||||||
|
secret.DiscordClientSecret,
|
||||||
|
],
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
actions: ["ses:SendEmail"],
|
||||||
|
resources: ["*"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
domain: {
|
||||||
|
name: "auth." + domain,
|
||||||
|
dns: sst.cloudflare.dns(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const outputs = {
|
||||||
|
auth: auth.url,
|
||||||
|
};
|
||||||
@@ -1,15 +1,18 @@
|
|||||||
|
import { vpc } from "./vpc";
|
||||||
import { email } from "./email";
|
import { email } from "./email";
|
||||||
import { allSecrets } from "./secret";
|
import { allSecrets } from "./secret";
|
||||||
import { database } from "./database";
|
import { postgres } from "./postgres";
|
||||||
|
|
||||||
export const bus = new sst.aws.Bus("Bus");
|
export const bus = new sst.aws.Bus("Bus");
|
||||||
|
|
||||||
bus.subscribe("Event", {
|
bus.subscribe("Event", {
|
||||||
|
vpc,
|
||||||
handler: "./packages/functions/src/event/event.handler",
|
handler: "./packages/functions/src/event/event.handler",
|
||||||
link: [
|
link: [
|
||||||
database,
|
|
||||||
email,
|
email,
|
||||||
...allSecrets],
|
postgres,
|
||||||
|
...allSecrets
|
||||||
|
],
|
||||||
timeout: "5 minutes",
|
timeout: "5 minutes",
|
||||||
permissions: [
|
permissions: [
|
||||||
{
|
{
|
||||||
|
|||||||
6
infra/cluster.ts
Normal file
6
infra/cluster.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { vpc } from "./vpc";
|
||||||
|
|
||||||
|
export const cluster = new sst.aws.Cluster("Cluster", {
|
||||||
|
vpc,
|
||||||
|
forceUpgrade: "v2"
|
||||||
|
});
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
//Created manually from the dashboard and shared with the whole team/org
|
|
||||||
const dbProject = neon.getProjectOutput({
|
|
||||||
id: "black-sky-26872933"
|
|
||||||
})
|
|
||||||
|
|
||||||
const dbBranchId = $app.stage !== "production" ?
|
|
||||||
new neon.Branch("NeonBranch", {
|
|
||||||
parentId: dbProject.defaultBranchId,
|
|
||||||
projectId: dbProject.id,
|
|
||||||
name: $app.stage,
|
|
||||||
}).id : dbProject.defaultBranchId
|
|
||||||
|
|
||||||
const dbEndpoint = new neon.Endpoint("NeonEndpoint", {
|
|
||||||
projectId: dbProject.id,
|
|
||||||
branchId: dbBranchId,
|
|
||||||
poolerEnabled: true,
|
|
||||||
type: "read_write",
|
|
||||||
})
|
|
||||||
|
|
||||||
const dbRole = new neon.Role("NeonRole", {
|
|
||||||
name: "admin",
|
|
||||||
branchId: dbBranchId,
|
|
||||||
projectId: dbProject.id,
|
|
||||||
})
|
|
||||||
|
|
||||||
const db = new neon.Database("NeonDatabase", {
|
|
||||||
branchId: dbBranchId,
|
|
||||||
projectId: dbProject.id,
|
|
||||||
ownerName: dbRole.name,
|
|
||||||
name: `nestri-${$app.stage}`,
|
|
||||||
})
|
|
||||||
|
|
||||||
export const database = new sst.Linkable("Database", {
|
|
||||||
properties: {
|
|
||||||
name: db.name,
|
|
||||||
user: dbRole.name,
|
|
||||||
host: dbEndpoint.host,
|
|
||||||
password: dbRole.password,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
68
infra/postgres.ts
Normal file
68
infra/postgres.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { vpc } from "./vpc";
|
||||||
|
import { isPermanentStage } from "./stage";
|
||||||
|
|
||||||
|
// TODO: Add a dev db to use, this will help with running zero locally... and testing it
|
||||||
|
export const postgres = new sst.aws.Aurora("Postgres", {
|
||||||
|
vpc,
|
||||||
|
engine: "postgres",
|
||||||
|
scaling: isPermanentStage
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
min: "0 ACU",
|
||||||
|
max: "1 ACU",
|
||||||
|
},
|
||||||
|
transform: {
|
||||||
|
clusterParameterGroup: {
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "rds.logical_replication",
|
||||||
|
value: "1",
|
||||||
|
applyMethod: "pending-reboot",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "max_slot_wal_keep_size",
|
||||||
|
value: "10240",
|
||||||
|
applyMethod: "pending-reboot",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rds.force_ssl",
|
||||||
|
value: "0",
|
||||||
|
applyMethod: "pending-reboot",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "max_connections",
|
||||||
|
value: "1000",
|
||||||
|
applyMethod: "pending-reboot",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
new sst.x.DevCommand("Studio", {
|
||||||
|
link: [postgres],
|
||||||
|
dev: {
|
||||||
|
command: "bun db studio",
|
||||||
|
directory: "packages/core",
|
||||||
|
autostart: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const migrator = new sst.aws.Function("DatabaseMigrator", {
|
||||||
|
handler: "packages/functions/src/migrator.handler",
|
||||||
|
link: [postgres],
|
||||||
|
copyFiles: [
|
||||||
|
{
|
||||||
|
from: "packages/core/migrations",
|
||||||
|
to: "./migrations",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!$dev) {
|
||||||
|
new aws.lambda.Invocation("DatabaseMigratorInvocation", {
|
||||||
|
input: Date.now().toString(),
|
||||||
|
functionName: migrator.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
2
infra/stage.ts
Normal file
2
infra/stage.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export const isPermanentStage =
|
||||||
|
$app.stage === "production" || $app.stage === "dev";
|
||||||
43
infra/steam.ts
Normal file
43
infra/steam.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { domain } from "./dns";
|
||||||
|
import { cluster } from "./cluster";
|
||||||
|
import { auth } from "./auth";
|
||||||
|
|
||||||
|
export const steam = new sst.aws.Service("Steam", {
|
||||||
|
cluster,
|
||||||
|
wait: true,
|
||||||
|
image: {
|
||||||
|
context: "packages/steam",
|
||||||
|
},
|
||||||
|
loadBalancer: {
|
||||||
|
domain:
|
||||||
|
$app.stage === "production"
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
name: "steam." + domain,
|
||||||
|
dns: sst.cloudflare.dns(),
|
||||||
|
},
|
||||||
|
rules: [
|
||||||
|
{ listen: "443/https", forward: "5289/http" },
|
||||||
|
{ listen: "80/http", forward: "5289/http" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
environment: {
|
||||||
|
NESTRI_AUTH_JWKS_URL: $interpolate`${auth.url}`
|
||||||
|
},
|
||||||
|
scaling:
|
||||||
|
$app.stage === "production"
|
||||||
|
? {
|
||||||
|
min: 2,
|
||||||
|
max: 4,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
logging: {
|
||||||
|
retention: "1 month",
|
||||||
|
},
|
||||||
|
architecture: "arm64",
|
||||||
|
dev: {
|
||||||
|
directory: "packages/steam",
|
||||||
|
command: "dotnet run",
|
||||||
|
url: "http://localhost:5289",
|
||||||
|
},
|
||||||
|
})
|
||||||
1
infra/storage.ts
Normal file
1
infra/storage.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const storage = new sst.aws.Bucket("Storage");
|
||||||
17
infra/vpc.ts
Normal file
17
infra/vpc.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// import { isPermanentStage } from "./stage";
|
||||||
|
|
||||||
|
// export const vpc = isPermanentStage
|
||||||
|
// ? new sst.aws.Vpc("Vpc", {
|
||||||
|
// az: 2,
|
||||||
|
// })
|
||||||
|
// //FIXME: Change this ID
|
||||||
|
// : undefined //sst.aws.Vpc.get("Vpc", "vpc-070a1a7598f4c12d1");
|
||||||
|
// //
|
||||||
|
|
||||||
|
export const vpc = new sst.aws.Vpc("NestriVpc", {
|
||||||
|
az: 2,
|
||||||
|
// For lambdas to work in this VPC
|
||||||
|
nat: "ec2",
|
||||||
|
// For SST tunnel to work
|
||||||
|
bastion: true
|
||||||
|
})
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
// This is the website part where people play and connect
|
// This is the website part where people play and connect
|
||||||
|
import { api } from "./api";
|
||||||
|
import { auth } from "./auth";
|
||||||
|
import { zero } from "./zero";
|
||||||
import { domain } from "./dns";
|
import { domain } from "./dns";
|
||||||
import { auth, api } from "./api";
|
import { steam } from "./steam";
|
||||||
|
|
||||||
new sst.aws.StaticSite("Web", {
|
new sst.aws.StaticSite("Web", {
|
||||||
path: "./packages/www",
|
path: "./packages/www",
|
||||||
@@ -14,7 +17,9 @@ new sst.aws.StaticSite("Web", {
|
|||||||
},
|
},
|
||||||
environment: {
|
environment: {
|
||||||
VITE_API_URL: api.url,
|
VITE_API_URL: api.url,
|
||||||
VITE_AUTH_URL: auth.url,
|
|
||||||
VITE_STAGE: $app.stage,
|
VITE_STAGE: $app.stage,
|
||||||
|
VITE_AUTH_URL: auth.url,
|
||||||
|
VITE_ZERO_URL: zero.url,
|
||||||
|
VITE_STEAM_URL: steam.url,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
196
infra/zero.ts
Normal file
196
infra/zero.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { vpc } from "./vpc";
|
||||||
|
import { auth } from "./auth";
|
||||||
|
import { domain } from "./dns";
|
||||||
|
import { readFileSync } from "fs";
|
||||||
|
import { cluster } from "./cluster";
|
||||||
|
import { storage } from "./storage";
|
||||||
|
import { postgres } from "./postgres";
|
||||||
|
|
||||||
|
// const connectionString = $interpolate`postgresql://${postgres.username}:${postgres.password}@${postgres.host}/${postgres.database}`
|
||||||
|
const connectionString = $interpolate`postgresql://${postgres.username}:${postgres.password}@${postgres.host}:${postgres.port}/${postgres.database}`;
|
||||||
|
|
||||||
|
const tag = $dev
|
||||||
|
? `latest`
|
||||||
|
: JSON.parse(
|
||||||
|
readFileSync("./node_modules/@rocicorp/zero/package.json").toString(),
|
||||||
|
).version.replace("+", "-");
|
||||||
|
|
||||||
|
const zeroEnv = {
|
||||||
|
FORCE: "1",
|
||||||
|
NO_COLOR: "1",
|
||||||
|
ZERO_LOG_LEVEL: "info",
|
||||||
|
ZERO_LITESTREAM_LOG_LEVEL: "info",
|
||||||
|
ZERO_UPSTREAM_DB: connectionString,
|
||||||
|
ZERO_IMAGE_URL: `rocicorp/zero:${tag}`,
|
||||||
|
ZERO_CVR_DB: connectionString,
|
||||||
|
ZERO_CHANGE_DB: connectionString,
|
||||||
|
ZERO_REPLICA_FILE: "/tmp/nestri.db",
|
||||||
|
ZERO_LITESTREAM_RESTORE_PARALLELISM: "64",
|
||||||
|
ZERO_SHARD_ID: $app.stage,
|
||||||
|
ZERO_AUTH_JWKS_URL: $interpolate`${auth.url}/.well-known/jwks.json`,
|
||||||
|
...($dev
|
||||||
|
? {
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
ZERO_LITESTREAM_BACKUP_URL: $interpolate`s3://${storage.name}/zero`,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Replication Manager Service
|
||||||
|
const replicationManager = !$dev
|
||||||
|
? new sst.aws.Service(`ZeroReplication`, {
|
||||||
|
cluster,
|
||||||
|
wait: true,
|
||||||
|
...($app.stage === "production"
|
||||||
|
? {
|
||||||
|
cpu: "2 vCPU",
|
||||||
|
memory: "4 GB",
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
architecture: "arm64",
|
||||||
|
image: zeroEnv.ZERO_IMAGE_URL,
|
||||||
|
link: [storage, postgres],
|
||||||
|
health: {
|
||||||
|
command: ["CMD-SHELL", "curl -f http://localhost:4849/ || exit 1"],
|
||||||
|
interval: "5 seconds",
|
||||||
|
retries: 3,
|
||||||
|
startPeriod: "300 seconds",
|
||||||
|
},
|
||||||
|
environment: {
|
||||||
|
...zeroEnv,
|
||||||
|
ZERO_CHANGE_MAX_CONNS: "3",
|
||||||
|
ZERO_NUM_SYNC_WORKERS: "0",
|
||||||
|
},
|
||||||
|
logging: {
|
||||||
|
retention: "1 month",
|
||||||
|
},
|
||||||
|
loadBalancer: {
|
||||||
|
public: false,
|
||||||
|
ports: [
|
||||||
|
{
|
||||||
|
listen: "80/http",
|
||||||
|
forward: "4849/http",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
transform: {
|
||||||
|
loadBalancer: {
|
||||||
|
idleTimeout: 3600,
|
||||||
|
},
|
||||||
|
service: {
|
||||||
|
healthCheckGracePeriodSeconds: 900,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}) : undefined;
|
||||||
|
|
||||||
|
// Permissions deployment
|
||||||
|
const permissions = new sst.aws.Function(
|
||||||
|
"ZeroPermissions",
|
||||||
|
{
|
||||||
|
vpc,
|
||||||
|
link: [postgres],
|
||||||
|
handler: "packages/functions/src/zero.handler",
|
||||||
|
// environment: { ["ZERO_UPSTREAM_DB"]: connectionString },
|
||||||
|
copyFiles: [{
|
||||||
|
from: "packages/zero/.permissions.sql",
|
||||||
|
to: "./.permissions.sql"
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (replicationManager) {
|
||||||
|
new aws.lambda.Invocation(
|
||||||
|
"ZeroPermissionsInvocation",
|
||||||
|
{
|
||||||
|
input: Date.now().toString(),
|
||||||
|
functionName: permissions.name,
|
||||||
|
},
|
||||||
|
{ dependsOn: replicationManager }
|
||||||
|
);
|
||||||
|
// new command.local.Command(
|
||||||
|
// "ZeroPermission",
|
||||||
|
// {
|
||||||
|
// dir: process.cwd() + "/packages/zero",
|
||||||
|
// environment: {
|
||||||
|
// ZERO_UPSTREAM_DB: connectionString,
|
||||||
|
// },
|
||||||
|
// create: "bun run zero-deploy-permissions",
|
||||||
|
// triggers: [Date.now()],
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// dependsOn: [replicationManager],
|
||||||
|
// },
|
||||||
|
// );
|
||||||
|
}
|
||||||
|
|
||||||
|
export const zero = new sst.aws.Service("Zero", {
|
||||||
|
cluster,
|
||||||
|
image: zeroEnv.ZERO_IMAGE_URL,
|
||||||
|
link: [storage, postgres],
|
||||||
|
architecture: "arm64",
|
||||||
|
...($app.stage === "production"
|
||||||
|
? {
|
||||||
|
cpu: "2 vCPU",
|
||||||
|
memory: "4 GB",
|
||||||
|
capacity: "spot"
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
capacity: "spot"
|
||||||
|
}),
|
||||||
|
environment: {
|
||||||
|
...zeroEnv,
|
||||||
|
...($dev
|
||||||
|
? {
|
||||||
|
ZERO_NUM_SYNC_WORKERS: "1",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
ZERO_CHANGE_STREAMER_URI: replicationManager.url.apply((val) =>
|
||||||
|
val.replace("http://", "ws://"),
|
||||||
|
),
|
||||||
|
ZERO_UPSTREAM_MAX_CONNS: "15",
|
||||||
|
ZERO_CVR_MAX_CONNS: "160",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
wait: true,
|
||||||
|
health: {
|
||||||
|
retries: 3,
|
||||||
|
command: ["CMD-SHELL", "curl -f http://localhost:4848/ || exit 1"],
|
||||||
|
interval: "5 seconds",
|
||||||
|
startPeriod: "300 seconds",
|
||||||
|
},
|
||||||
|
loadBalancer: {
|
||||||
|
domain: {
|
||||||
|
name: "zero." + domain,
|
||||||
|
dns: sst.cloudflare.dns()
|
||||||
|
},
|
||||||
|
rules: [
|
||||||
|
{ listen: "443/https", forward: "4848/http" },
|
||||||
|
{ listen: "80/http", forward: "4848/http" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
scaling: {
|
||||||
|
min: 1,
|
||||||
|
max: 4,
|
||||||
|
},
|
||||||
|
logging: {
|
||||||
|
retention: "1 month",
|
||||||
|
},
|
||||||
|
transform: {
|
||||||
|
service: {
|
||||||
|
healthCheckGracePeriodSeconds: 900,
|
||||||
|
},
|
||||||
|
// taskDefinition: {
|
||||||
|
// ephemeralStorage: {
|
||||||
|
// sizeInGib: 200,
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
loadBalancer: {
|
||||||
|
idleTimeout: 3600,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dev: {
|
||||||
|
command: "bun dev",
|
||||||
|
directory: "packages/zero",
|
||||||
|
url: "http://localhost:4848",
|
||||||
|
},
|
||||||
|
});
|
||||||
29
nestri.sln
Normal file
29
nestri.sln
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio Version 17
|
||||||
|
VisualStudioVersion = 17.5.2.0
|
||||||
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "packages", "packages", "{809F86A1-1C4C-B159-0CD4-DF9D33D876CE}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "steam", "packages\steam\steam.csproj", "{96118F95-BF02-0ED3-9042-36FA1B740D67}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{96118F95-BF02-0ED3-9042-36FA1B740D67}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{96118F95-BF02-0ED3-9042-36FA1B740D67}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{96118F95-BF02-0ED3-9042-36FA1B740D67}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{96118F95-BF02-0ED3-9042-36FA1B740D67}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(NestedProjects) = preSolution
|
||||||
|
{96118F95-BF02-0ED3-9042-36FA1B740D67} = {809F86A1-1C4C-B159-0CD4-DF9D33D876CE}
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
|
SolutionGuid = {526AD703-4D15-43CF-B7C0-83F10D3158DB}
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
12
package.json
12
package.json
@@ -16,9 +16,19 @@
|
|||||||
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
|
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
|
||||||
"sso": "aws sso login --sso-session=nestri --no-browser --use-device-code"
|
"sso": "aws sso login --sso-session=nestri --no-browser --use-device-code"
|
||||||
},
|
},
|
||||||
|
"overrides": {
|
||||||
|
"@openauthjs/openauth": "0.4.3",
|
||||||
|
"@rocicorp/zero": "0.16.2025022000"
|
||||||
|
},
|
||||||
|
"patchedDependencies": {
|
||||||
|
"@macaron-css/solid@1.5.3": "patches/@macaron-css%2Fsolid@1.5.3.patch",
|
||||||
|
"drizzle-orm@0.36.1": "patches/drizzle-orm@0.36.1.patch"
|
||||||
|
},
|
||||||
"trustedDependencies": [
|
"trustedDependencies": [
|
||||||
"core-js-pure",
|
"core-js-pure",
|
||||||
"esbuild",
|
"esbuild",
|
||||||
|
"protobufjs",
|
||||||
|
"@rocicorp/zero-sqlite3",
|
||||||
"workerd"
|
"workerd"
|
||||||
],
|
],
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -26,6 +36,6 @@
|
|||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"sst": "3.9.1"
|
"sst": "3.9.36"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,19 @@
|
|||||||
import { Resource } from "sst";
|
import { Resource } from "sst";
|
||||||
import { defineConfig } from "drizzle-kit";
|
import { defineConfig } from "drizzle-kit";
|
||||||
|
|
||||||
function addPoolerSuffix(original: string): string {
|
const connection = {
|
||||||
const firstDotIndex = original.indexOf('.');
|
user: Resource.Postgres.username,
|
||||||
if (firstDotIndex === -1) return original + '-pooler';
|
password: Resource.Postgres.password,
|
||||||
return original.slice(0, firstDotIndex) + '-pooler' + original.slice(firstDotIndex);
|
host: Resource.Postgres.host,
|
||||||
}
|
};
|
||||||
|
|
||||||
const dbHost = addPoolerSuffix(Resource.Database.host)
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
schema: "./src/**/*.sql.ts",
|
verbose: true,
|
||||||
|
strict: true,
|
||||||
out: "./migrations",
|
out: "./migrations",
|
||||||
dialect: "postgresql",
|
dialect: "postgresql",
|
||||||
verbose: true,
|
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
url: `postgresql://${Resource.Database.user}:${Resource.Database.password}@${dbHost}/${Resource.Database.name}?sslmode=require`,
|
url: `postgres://${connection.user}:${connection.password}@${connection.host}/nestri`,
|
||||||
},
|
},
|
||||||
|
schema: "./src/**/*.sql.ts",
|
||||||
});
|
});
|
||||||
@@ -14,8 +14,9 @@ CREATE TABLE "team" (
|
|||||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
"time_deleted" timestamp with time zone,
|
"time_deleted" timestamp with time zone,
|
||||||
|
"name" varchar(255) NOT NULL,
|
||||||
"slug" varchar(255) NOT NULL,
|
"slug" varchar(255) NOT NULL,
|
||||||
"name" varchar(255) NOT NULL
|
"plan_type" text NOT NULL
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
CREATE TABLE "user" (
|
CREATE TABLE "user" (
|
||||||
@@ -24,14 +25,15 @@ CREATE TABLE "user" (
|
|||||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
"time_deleted" timestamp with time zone,
|
"time_deleted" timestamp with time zone,
|
||||||
"avatar_url" text,
|
"avatar_url" text,
|
||||||
"email" varchar(255) NOT NULL,
|
|
||||||
"name" varchar(255) NOT NULL,
|
"name" varchar(255) NOT NULL,
|
||||||
"discriminator" integer NOT NULL,
|
"discriminator" integer NOT NULL,
|
||||||
"polar_customer_id" varchar(255) NOT NULL,
|
"email" varchar(255) NOT NULL,
|
||||||
|
"polar_customer_id" varchar(255),
|
||||||
|
"flags" json DEFAULT '{}'::json,
|
||||||
CONSTRAINT "user_polar_customer_id_unique" UNIQUE("polar_customer_id")
|
CONSTRAINT "user_polar_customer_id_unique" UNIQUE("polar_customer_id")
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
CREATE UNIQUE INDEX "member_email" ON "member" USING btree ("team_id","email");--> statement-breakpoint
|
|
||||||
CREATE INDEX "email_global" ON "member" USING btree ("email");--> statement-breakpoint
|
CREATE INDEX "email_global" ON "member" USING btree ("email");--> statement-breakpoint
|
||||||
CREATE UNIQUE INDEX "slug" ON "team" USING btree ("slug");--> statement-breakpoint
|
CREATE UNIQUE INDEX "member_email" ON "member" USING btree ("team_id","email");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "team_slug" ON "team" USING btree ("slug");--> statement-breakpoint
|
||||||
CREATE UNIQUE INDEX "user_email" ON "user" USING btree ("email");
|
CREATE UNIQUE INDEX "user_email" ON "user" USING btree ("email");
|
||||||
@@ -1 +0,0 @@
|
|||||||
ALTER TABLE "user" ALTER COLUMN "polar_customer_id" DROP NOT NULL;
|
|
||||||
2
packages/core/migrations/0001_nifty_sauron.sql
Normal file
2
packages/core/migrations/0001_nifty_sauron.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
DROP INDEX "team_slug";--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "slug" ON "team" USING btree ("slug");
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"id": "08ba0262-ce0a-4d87-b4e2-0d17dc0ee28c",
|
"id": "f09034df-208a-42b3-b61f-f842921c6e24",
|
||||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"dialect": "postgresql",
|
"dialect": "postgresql",
|
||||||
@@ -54,6 +54,21 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {
|
"indexes": {
|
||||||
|
"email_global": {
|
||||||
|
"name": "email_global",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "email",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
"member_email": {
|
"member_email": {
|
||||||
"name": "member_email",
|
"name": "member_email",
|
||||||
"columns": [
|
"columns": [
|
||||||
@@ -74,21 +89,6 @@
|
|||||||
"concurrently": false,
|
"concurrently": false,
|
||||||
"method": "btree",
|
"method": "btree",
|
||||||
"with": {}
|
"with": {}
|
||||||
},
|
|
||||||
"email_global": {
|
|
||||||
"name": "email_global",
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"expression": "email",
|
|
||||||
"isExpression": false,
|
|
||||||
"asc": true,
|
|
||||||
"nulls": "last"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"isUnique": false,
|
|
||||||
"concurrently": false,
|
|
||||||
"method": "btree",
|
|
||||||
"with": {}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"foreignKeys": {},
|
"foreignKeys": {},
|
||||||
@@ -136,22 +136,28 @@
|
|||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false
|
"notNull": false
|
||||||
},
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
"slug": {
|
"slug": {
|
||||||
"name": "slug",
|
"name": "slug",
|
||||||
"type": "varchar(255)",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true
|
"notNull": true
|
||||||
},
|
},
|
||||||
"name": {
|
"plan_type": {
|
||||||
"name": "name",
|
"name": "plan_type",
|
||||||
"type": "varchar(255)",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true
|
"notNull": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {
|
"indexes": {
|
||||||
"slug": {
|
"team_slug": {
|
||||||
"name": "slug",
|
"name": "team_slug",
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
"expression": "slug",
|
"expression": "slug",
|
||||||
@@ -209,12 +215,6 @@
|
|||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false
|
"notNull": false
|
||||||
},
|
},
|
||||||
"email": {
|
|
||||||
"name": "email",
|
|
||||||
"type": "varchar(255)",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"name": {
|
"name": {
|
||||||
"name": "name",
|
"name": "name",
|
||||||
"type": "varchar(255)",
|
"type": "varchar(255)",
|
||||||
@@ -227,11 +227,24 @@
|
|||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true
|
"notNull": true
|
||||||
},
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
"polar_customer_id": {
|
"polar_customer_id": {
|
||||||
"name": "polar_customer_id",
|
"name": "polar_customer_id",
|
||||||
"type": "varchar(255)",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true
|
"notNull": false
|
||||||
|
},
|
||||||
|
"flags": {
|
||||||
|
"name": "flags",
|
||||||
|
"type": "json",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "'{}'::json"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {
|
"indexes": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"id": "c09359df-19fe-4246-9a41-43b3a429c12f",
|
"id": "6f428226-b5d8-4182-a676-d04f842f9ded",
|
||||||
"prevId": "08ba0262-ce0a-4d87-b4e2-0d17dc0ee28c",
|
"prevId": "f09034df-208a-42b3-b61f-f842921c6e24",
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"dialect": "postgresql",
|
"dialect": "postgresql",
|
||||||
"tables": {
|
"tables": {
|
||||||
@@ -54,6 +54,21 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {
|
"indexes": {
|
||||||
|
"email_global": {
|
||||||
|
"name": "email_global",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "email",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
"member_email": {
|
"member_email": {
|
||||||
"name": "member_email",
|
"name": "member_email",
|
||||||
"columns": [
|
"columns": [
|
||||||
@@ -74,21 +89,6 @@
|
|||||||
"concurrently": false,
|
"concurrently": false,
|
||||||
"method": "btree",
|
"method": "btree",
|
||||||
"with": {}
|
"with": {}
|
||||||
},
|
|
||||||
"email_global": {
|
|
||||||
"name": "email_global",
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"expression": "email",
|
|
||||||
"isExpression": false,
|
|
||||||
"asc": true,
|
|
||||||
"nulls": "last"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"isUnique": false,
|
|
||||||
"concurrently": false,
|
|
||||||
"method": "btree",
|
|
||||||
"with": {}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"foreignKeys": {},
|
"foreignKeys": {},
|
||||||
@@ -136,15 +136,21 @@
|
|||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false
|
"notNull": false
|
||||||
},
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
"slug": {
|
"slug": {
|
||||||
"name": "slug",
|
"name": "slug",
|
||||||
"type": "varchar(255)",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true
|
"notNull": true
|
||||||
},
|
},
|
||||||
"name": {
|
"plan_type": {
|
||||||
"name": "name",
|
"name": "plan_type",
|
||||||
"type": "varchar(255)",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true
|
"notNull": true
|
||||||
}
|
}
|
||||||
@@ -209,12 +215,6 @@
|
|||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false
|
"notNull": false
|
||||||
},
|
},
|
||||||
"email": {
|
|
||||||
"name": "email",
|
|
||||||
"type": "varchar(255)",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"name": {
|
"name": {
|
||||||
"name": "name",
|
"name": "name",
|
||||||
"type": "varchar(255)",
|
"type": "varchar(255)",
|
||||||
@@ -227,11 +227,24 @@
|
|||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true
|
"notNull": true
|
||||||
},
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
"polar_customer_id": {
|
"polar_customer_id": {
|
||||||
"name": "polar_customer_id",
|
"name": "polar_customer_id",
|
||||||
"type": "varchar(255)",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false
|
"notNull": false
|
||||||
|
},
|
||||||
|
"flags": {
|
||||||
|
"name": "flags",
|
||||||
|
"type": "json",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "'{}'::json"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {
|
"indexes": {
|
||||||
|
|||||||
@@ -5,15 +5,15 @@
|
|||||||
{
|
{
|
||||||
"idx": 0,
|
"idx": 0,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1740345380808,
|
"when": 1741759978256,
|
||||||
"tag": "0000_wise_black_widow",
|
"tag": "0000_flaky_matthew_murdock",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 1,
|
"idx": 1,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1740487217291,
|
"when": 1741955636085,
|
||||||
"tag": "0001_flaky_tomorrow_man",
|
"tag": "0001_nifty_sauron",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,12 +4,10 @@
|
|||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
"db": "sst shell drizzle-kit",
|
"db": "sst shell drizzle-kit",
|
||||||
"db:push": "sst shell drizzle-kit push",
|
"db:exec": "sst shell ../scripts/src/psql.sh",
|
||||||
"db:migrate": "sst shell drizzle-kit migrate",
|
"db:reset": "sst shell ../scripts/src/db-reset.sh"
|
||||||
"db:generate": "sst shell drizzle-kit generate",
|
|
||||||
"db:connect": "sst shell ../scripts/src/psql.ts",
|
|
||||||
"db:move": "sst shell drizzle-kit generate && sst shell drizzle-kit migrate && sst shell drizzle-kit push"
|
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
"./*": "./src/*.ts"
|
"./*": "./src/*.ts"
|
||||||
@@ -18,7 +16,6 @@
|
|||||||
"@tsconfig/node20": "^20.1.4",
|
"@tsconfig/node20": "^20.1.4",
|
||||||
"aws-iot-device-sdk-v2": "^1.21.1",
|
"aws-iot-device-sdk-v2": "^1.21.1",
|
||||||
"aws4fetch": "^1.0.20",
|
"aws4fetch": "^1.0.20",
|
||||||
"drizzle-kit": "^0.30.4",
|
|
||||||
"loops": "^3.4.1",
|
"loops": "^3.4.1",
|
||||||
"mqtt": "^5.10.3",
|
"mqtt": "^5.10.3",
|
||||||
"remeda": "^2.19.0",
|
"remeda": "^2.19.0",
|
||||||
@@ -28,13 +25,14 @@
|
|||||||
"zod-openapi": "^4.2.2"
|
"zod-openapi": "^4.2.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-rds-data": "^3.758.0",
|
||||||
"@aws-sdk/client-sesv2": "^3.753.0",
|
"@aws-sdk/client-sesv2": "^3.753.0",
|
||||||
"@instantdb/admin": "^0.17.7",
|
"@instantdb/admin": "^0.17.7",
|
||||||
"@neondatabase/serverless": "^0.10.4",
|
"@openauthjs/openauth": "*",
|
||||||
"@openauthjs/openauth": "0.4.3",
|
|
||||||
"@openauthjs/openevent": "^0.0.27",
|
"@openauthjs/openevent": "^0.0.27",
|
||||||
"@polar-sh/sdk": "^0.26.1",
|
"@polar-sh/sdk": "^0.26.1",
|
||||||
"drizzle-orm": "^0.39.3",
|
"drizzle-kit": "^0.30.5",
|
||||||
"ws": "^8.18.1"
|
"drizzle-orm": "^0.40.0",
|
||||||
|
"postgres": "^3.4.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,30 +1,17 @@
|
|||||||
export * from "drizzle-orm";
|
export * from "drizzle-orm";
|
||||||
import ws from 'ws';
|
|
||||||
import { Resource } from "sst";
|
import { Resource } from "sst";
|
||||||
import { drizzle as neonDrizzle, NeonDatabase } from "drizzle-orm/neon-serverless";
|
import postgres from "postgres";
|
||||||
// import { drizzle } from 'drizzle-orm/postgres-js';
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
import { Pool, neonConfig } from "@neondatabase/serverless";
|
|
||||||
|
|
||||||
neonConfig.webSocketConstructor = ws;
|
const client = postgres({
|
||||||
|
idle_timeout: 30000,
|
||||||
function addPoolerSuffix(original: string): string {
|
connect_timeout: 30000,
|
||||||
const firstDotIndex = original.indexOf('.');
|
host: Resource.Postgres.host,
|
||||||
if (firstDotIndex === -1) return original + '-pooler';
|
database: Resource.Postgres.database,
|
||||||
return original.slice(0, firstDotIndex) + '-pooler' + original.slice(firstDotIndex);
|
user: Resource.Postgres.username,
|
||||||
}
|
password: Resource.Postgres.password,
|
||||||
|
port: Resource.Postgres.port,
|
||||||
const dbHost = addPoolerSuffix(Resource.Database.host)
|
max: parseInt(process.env.POSTGRES_POOL_MAX || "1"),
|
||||||
|
|
||||||
const client = new Pool({ connectionString: `postgres://${Resource.Database.user}:${Resource.Database.password}@${dbHost}/${Resource.Database.name}?sslmode=require` })
|
|
||||||
|
|
||||||
export const db = neonDrizzle(client, {
|
|
||||||
logger:
|
|
||||||
process.env.DRIZZLE_LOG === "true"
|
|
||||||
? {
|
|
||||||
logQuery(query, params) {
|
|
||||||
console.log("query", query);
|
|
||||||
console.log("params", params);
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const db = drizzle(client, {});
|
||||||
@@ -4,14 +4,13 @@ import {
|
|||||||
PgTransactionConfig
|
PgTransactionConfig
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
import {
|
import {
|
||||||
NeonQueryResultHKT
|
PostgresJsQueryResultHKT
|
||||||
// NeonHttpQueryResultHKT
|
} from "drizzle-orm/postgres-js";
|
||||||
} from "drizzle-orm/neon-serverless";
|
|
||||||
import { ExtractTablesWithRelations } from "drizzle-orm";
|
import { ExtractTablesWithRelations } from "drizzle-orm";
|
||||||
import { createContext } from "../context";
|
import { createContext } from "../context";
|
||||||
|
|
||||||
export type Transaction = PgTransaction<
|
export type Transaction = PgTransaction<
|
||||||
NeonQueryResultHKT,
|
PostgresJsQueryResultHKT,
|
||||||
Record<string, never>,
|
Record<string, never>,
|
||||||
ExtractTablesWithRelations<Record<string, never>>
|
ExtractTablesWithRelations<Record<string, never>>
|
||||||
>;
|
>;
|
||||||
@@ -59,7 +58,6 @@ export async function createTransaction<T>(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
await Promise.all(effects.map((x) => x()));
|
await Promise.all(effects.map((x) => x()));
|
||||||
// await db.$client.end()
|
|
||||||
return result as T;
|
return result as T;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,145 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard error response schema used for OpenAPI documentation
|
||||||
|
*/
|
||||||
|
export const ErrorResponse = z
|
||||||
|
.object({
|
||||||
|
type: z
|
||||||
|
.enum([
|
||||||
|
"validation",
|
||||||
|
"authentication",
|
||||||
|
"forbidden",
|
||||||
|
"not_found",
|
||||||
|
"already_exists",
|
||||||
|
"rate_limit",
|
||||||
|
"internal",
|
||||||
|
])
|
||||||
|
.openapi({
|
||||||
|
description: "The error type category",
|
||||||
|
examples: ["validation", "authentication"],
|
||||||
|
}),
|
||||||
|
code: z.string().openapi({
|
||||||
|
description: "Machine-readable error code identifier",
|
||||||
|
examples: ["invalid_parameter", "missing_required_field", "unauthorized"],
|
||||||
|
}),
|
||||||
|
message: z.string().openapi({
|
||||||
|
description: "Human-readable error message",
|
||||||
|
examples: ["The request was invalid", "Authentication required"],
|
||||||
|
}),
|
||||||
|
param: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.openapi({
|
||||||
|
description: "The parameter that caused the error (if applicable)",
|
||||||
|
examples: ["email", "user_id", "team_id"],
|
||||||
|
}),
|
||||||
|
details: z.any().optional().openapi({
|
||||||
|
description: "Additional error context information",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.openapi({ ref: "ErrorResponse" });
|
||||||
|
|
||||||
|
export type ErrorResponseType = z.infer<typeof ErrorResponse>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standardized error codes for the API
|
||||||
|
*/
|
||||||
|
export const ErrorCodes = {
|
||||||
|
// Validation errors (400)
|
||||||
|
Validation: {
|
||||||
|
MISSING_REQUIRED_FIELD: "missing_required_field",
|
||||||
|
ALREADY_EXISTS: "resource_already_exists",
|
||||||
|
TEAM_ALREADY_EXISTS: "team_already_exists",
|
||||||
|
INVALID_PARAMETER: "invalid_parameter",
|
||||||
|
INVALID_FORMAT: "invalid_format",
|
||||||
|
INVALID_STATE: "invalid_state",
|
||||||
|
IN_USE: "resource_in_use",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Authentication errors (401)
|
||||||
|
Authentication: {
|
||||||
|
UNAUTHORIZED: "unauthorized",
|
||||||
|
INVALID_TOKEN: "invalid_token",
|
||||||
|
EXPIRED_TOKEN: "expired_token",
|
||||||
|
INVALID_CREDENTIALS: "invalid_credentials",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Permission errors (403)
|
||||||
|
Permission: {
|
||||||
|
FORBIDDEN: "forbidden",
|
||||||
|
INSUFFICIENT_PERMISSIONS: "insufficient_permissions",
|
||||||
|
ACCOUNT_RESTRICTED: "account_restricted",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Resource not found errors (404)
|
||||||
|
NotFound: {
|
||||||
|
RESOURCE_NOT_FOUND: "resource_not_found",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Rate limit errors (429)
|
||||||
|
RateLimit: {
|
||||||
|
TOO_MANY_REQUESTS: "too_many_requests",
|
||||||
|
QUOTA_EXCEEDED: "quota_exceeded",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Server errors (500)
|
||||||
|
Server: {
|
||||||
|
INTERNAL_ERROR: "internal_error",
|
||||||
|
SERVICE_UNAVAILABLE: "service_unavailable",
|
||||||
|
DEPENDENCY_FAILURE: "dependency_failure",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard error that will be exposed to clients through API responses
|
||||||
|
*/
|
||||||
export class VisibleError extends Error {
|
export class VisibleError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
public code: string,
|
public type: ErrorResponseType["type"],
|
||||||
public message: string,
|
public code: string,
|
||||||
) {
|
public message: string,
|
||||||
super(message);
|
public param?: string,
|
||||||
|
public details?: any,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert this error to an HTTP status code
|
||||||
|
*/
|
||||||
|
public statusCode(): number {
|
||||||
|
switch (this.type) {
|
||||||
|
case "validation":
|
||||||
|
return 400;
|
||||||
|
case "authentication":
|
||||||
|
return 401;
|
||||||
|
case "forbidden":
|
||||||
|
return 403;
|
||||||
|
case "not_found":
|
||||||
|
return 404;
|
||||||
|
case "already_exists":
|
||||||
|
return 409;
|
||||||
|
case "rate_limit":
|
||||||
|
return 429;
|
||||||
|
case "internal":
|
||||||
|
return 500;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert this error to a standard response object
|
||||||
|
*/
|
||||||
|
public toResponse(): ErrorResponseType {
|
||||||
|
const response: ErrorResponseType = {
|
||||||
|
type: this.type,
|
||||||
|
code: this.code,
|
||||||
|
message: this.message,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.param) response.param = this.param;
|
||||||
|
if (this.details) response.details = this.details;
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ export module Examples {
|
|||||||
id: Id("team"),
|
id: Id("team"),
|
||||||
name: "John Does' Team",
|
name: "John Does' Team",
|
||||||
slug: "john_doe",
|
slug: "john_doe",
|
||||||
|
planType: "BYOG" as const
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Member = {
|
export const Member = {
|
||||||
|
|||||||
@@ -66,15 +66,11 @@ export module Member {
|
|||||||
const id = input.id ?? createID("member");
|
const id = input.id ?? createID("member");
|
||||||
await tx.insert(memberTable).values({
|
await tx.insert(memberTable).values({
|
||||||
id,
|
id,
|
||||||
email: input.email,
|
|
||||||
teamID: useTeam(),
|
teamID: useTeam(),
|
||||||
timeSeen: input.first ? sql`CURRENT_TIMESTAMP()` : null,
|
email: input.email,
|
||||||
}).onConflictDoUpdate({
|
timeSeen: input.first ? sql`now()` : null,
|
||||||
target: memberTable.id,
|
|
||||||
set: {
|
|
||||||
timeDeleted: null,
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
await afterTx(() =>
|
await afterTx(() =>
|
||||||
async () => bus.publish(Resource.Bus, Events.Created, { memberID: id }),
|
async () => bus.publish(Resource.Bus, Events.Created, { memberID: id }),
|
||||||
);
|
);
|
||||||
@@ -87,7 +83,7 @@ export module Member {
|
|||||||
await tx
|
await tx
|
||||||
.update(memberTable)
|
.update(memberTable)
|
||||||
.set({
|
.set({
|
||||||
timeDeleted: sql`CURRENT_TIMESTAMP()`,
|
timeDeleted: sql`now()`,
|
||||||
})
|
})
|
||||||
.where(and(eq(memberTable.id, input), eq(memberTable.teamID, useTeam())))
|
.where(and(eq(memberTable.id, input), eq(memberTable.teamID, useTeam())))
|
||||||
.execute();
|
.execute();
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export const memberTable = pgTable(
|
|||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
...teamIndexes(table),
|
...teamIndexes(table),
|
||||||
uniqueIndex("member_email").on(table.teamID, table.email),
|
|
||||||
index("email_global").on(table.email),
|
index("email_global").on(table.email),
|
||||||
|
uniqueIndex("member_email").on(table.teamID, table.email),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -4,7 +4,7 @@ import { Resource } from "sst";
|
|||||||
import { eq, and } from "../drizzle";
|
import { eq, and } from "../drizzle";
|
||||||
import { useTeam } from "../actor";
|
import { useTeam } from "../actor";
|
||||||
import { createEvent } from "../event";
|
import { createEvent } from "../event";
|
||||||
import { polarTable, Standing } from "./polar.sql";
|
// import { polarTable, Standing } from "./polar.sql.ts.test";
|
||||||
import { Polar as PolarSdk } from "@polar-sh/sdk";
|
import { Polar as PolarSdk } from "@polar-sh/sdk";
|
||||||
import { useTransaction } from "../drizzle/transaction";
|
import { useTransaction } from "../drizzle/transaction";
|
||||||
|
|
||||||
@@ -15,10 +15,10 @@ export module Polar {
|
|||||||
|
|
||||||
export const Info = z.object({
|
export const Info = z.object({
|
||||||
teamID: z.string(),
|
teamID: z.string(),
|
||||||
customerID: z.string(),
|
|
||||||
subscriptionID: z.string().nullable(),
|
subscriptionID: z.string().nullable(),
|
||||||
|
customerID: z.string(),
|
||||||
subscriptionItemID: z.string().nullable(),
|
subscriptionItemID: z.string().nullable(),
|
||||||
standing: z.enum(Standing),
|
// standing: z.enum(Standing),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Info = z.infer<typeof Info>;
|
export type Info = z.infer<typeof Info>;
|
||||||
@@ -53,16 +53,16 @@ export module Polar {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export function get() {
|
// export function get() {
|
||||||
return useTransaction(async (tx) =>
|
// return useTransaction(async (tx) =>
|
||||||
tx
|
// tx
|
||||||
.select()
|
// .select()
|
||||||
.from(polarTable)
|
// .from(polarTable)
|
||||||
.where(eq(polarTable.teamID, useTeam()))
|
// .where(eq(polarTable.teamID, useTeam()))
|
||||||
.execute()
|
// .execute()
|
||||||
.then((rows) => rows.map(serialize).at(0)),
|
// .then((rows) => rows.map(serialize).at(0)),
|
||||||
);
|
// );
|
||||||
}
|
// }
|
||||||
|
|
||||||
export const fromUserEmail = fn(z.string().min(1), async (email) => {
|
export const fromUserEmail = fn(z.string().min(1), async (email) => {
|
||||||
try {
|
try {
|
||||||
@@ -81,89 +81,89 @@ export module Polar {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export const setCustomerID = fn(Info.shape.customerID, async (customerID) =>
|
// export const setCustomerID = fn(Info.shape.customerID, async (customerID) =>
|
||||||
useTransaction(async (tx) =>
|
// useTransaction(async (tx) =>
|
||||||
tx
|
// tx
|
||||||
.insert(polarTable)
|
// .insert(polarTable)
|
||||||
.values({
|
// .values({
|
||||||
teamID: useTeam(),
|
// teamID: useTeam(),
|
||||||
customerID,
|
// customerID,
|
||||||
standing: "new",
|
// standing: "new",
|
||||||
})
|
// })
|
||||||
.execute(),
|
// .execute(),
|
||||||
),
|
// ),
|
||||||
);
|
// );
|
||||||
|
|
||||||
export const setSubscription = fn(
|
// export const setSubscription = fn(
|
||||||
Info.pick({
|
// Info.pick({
|
||||||
subscriptionID: true,
|
// subscriptionID: true,
|
||||||
subscriptionItemID: true,
|
// subscriptionItemID: true,
|
||||||
}),
|
// }),
|
||||||
(input) =>
|
// (input) =>
|
||||||
useTransaction(async (tx) =>
|
// useTransaction(async (tx) =>
|
||||||
tx
|
// tx
|
||||||
.update(polarTable)
|
// .update(polarTable)
|
||||||
.set({
|
// .set({
|
||||||
subscriptionID: input.subscriptionID,
|
// subscriptionID: input.subscriptionID,
|
||||||
subscriptionItemID: input.subscriptionItemID,
|
// subscriptionItemID: input.subscriptionItemID,
|
||||||
})
|
// })
|
||||||
.where(eq(polarTable.teamID, useTeam()))
|
// .where(eq(polarTable.teamID, useTeam()))
|
||||||
.returning()
|
// .returning()
|
||||||
.execute()
|
// .execute()
|
||||||
.then((rows) => rows.map(serialize).at(0)),
|
// .then((rows) => rows.map(serialize).at(0)),
|
||||||
),
|
// ),
|
||||||
);
|
// );
|
||||||
|
|
||||||
export const removeSubscription = fn(
|
// export const removeSubscription = fn(
|
||||||
z.string().min(1),
|
// z.string().min(1),
|
||||||
(stripeSubscriptionID) =>
|
// (stripeSubscriptionID) =>
|
||||||
useTransaction((tx) =>
|
// useTransaction((tx) =>
|
||||||
tx
|
// tx
|
||||||
.update(polarTable)
|
// .update(polarTable)
|
||||||
.set({
|
// .set({
|
||||||
subscriptionItemID: null,
|
// subscriptionItemID: null,
|
||||||
subscriptionID: null,
|
// subscriptionID: null,
|
||||||
})
|
// })
|
||||||
.where(and(eq(polarTable.subscriptionID, stripeSubscriptionID)))
|
// .where(and(eq(polarTable.subscriptionID, stripeSubscriptionID)))
|
||||||
.execute(),
|
// .execute(),
|
||||||
),
|
// ),
|
||||||
);
|
// );
|
||||||
|
|
||||||
export const setStanding = fn(
|
// export const setStanding = fn(
|
||||||
Info.pick({
|
// Info.pick({
|
||||||
subscriptionID: true,
|
// subscriptionID: true,
|
||||||
standing: true,
|
// standing: true,
|
||||||
}),
|
// }),
|
||||||
(input) =>
|
// (input) =>
|
||||||
useTransaction((tx) =>
|
// useTransaction((tx) =>
|
||||||
tx
|
// tx
|
||||||
.update(polarTable)
|
// .update(polarTable)
|
||||||
.set({ standing: input.standing })
|
// .set({ standing: input.standing })
|
||||||
.where(and(eq(polarTable.subscriptionID, input.subscriptionID!)))
|
// .where(and(eq(polarTable.subscriptionID, input.subscriptionID!)))
|
||||||
.execute(),
|
// .execute(),
|
||||||
),
|
// ),
|
||||||
);
|
// );
|
||||||
|
|
||||||
export const fromCustomerID = fn(Info.shape.customerID, (customerID) =>
|
// export const fromCustomerID = fn(Info.shape.customerID, (customerID) =>
|
||||||
useTransaction((tx) =>
|
// useTransaction((tx) =>
|
||||||
tx
|
// tx
|
||||||
.select()
|
// .select()
|
||||||
.from(polarTable)
|
// .from(polarTable)
|
||||||
.where(and(eq(polarTable.customerID, customerID)))
|
// .where(and(eq(polarTable.customerID, customerID)))
|
||||||
.execute()
|
// .execute()
|
||||||
.then((rows) => rows.map(serialize).at(0)),
|
// .then((rows) => rows.map(serialize).at(0)),
|
||||||
),
|
// ),
|
||||||
);
|
// );
|
||||||
|
|
||||||
function serialize(
|
// function serialize(
|
||||||
input: typeof polarTable.$inferSelect,
|
// input: typeof polarTable.$inferSelect,
|
||||||
): z.infer<typeof Info> {
|
// ): z.infer<typeof Info> {
|
||||||
return {
|
// return {
|
||||||
teamID: input.teamID,
|
// teamID: input.teamID,
|
||||||
customerID: input.customerID,
|
// customerID: input.customerID,
|
||||||
subscriptionID: input.subscriptionID,
|
// subscriptionID: input.subscriptionID,
|
||||||
subscriptionItemID: input.subscriptionItemID,
|
// subscriptionItemID: input.subscriptionItemID,
|
||||||
standing: input.standing,
|
// standing: input.standing,
|
||||||
};
|
// };
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ import { timestamps, teamID } from "../drizzle/types";
|
|||||||
import { teamIndexes, teamTable } from "../team/team.sql";
|
import { teamIndexes, teamTable } from "../team/team.sql";
|
||||||
import { pgTable, text, varchar } from "drizzle-orm/pg-core";
|
import { pgTable, text, varchar } from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
// FIXME: This is causing errors while trying to db push
|
||||||
export const Standing = ["new", "good", "overdue"] as const;
|
export const Standing = ["new", "good", "overdue"] as const;
|
||||||
|
|
||||||
export const polarTable = pgTable(
|
export const polarTable = pgTable(
|
||||||
@@ -16,7 +17,7 @@ export const polarTable = pgTable(
|
|||||||
}),
|
}),
|
||||||
standing: text("standing", { enum: Standing }).notNull(),
|
standing: text("standing", { enum: Standing }).notNull(),
|
||||||
},
|
},
|
||||||
(table) => ({
|
(table) => [
|
||||||
...teamIndexes(table),
|
...teamIndexes(table),
|
||||||
})
|
]
|
||||||
)
|
)
|
||||||
@@ -2,14 +2,14 @@ import { z } from "zod";
|
|||||||
import { Resource } from "sst";
|
import { Resource } from "sst";
|
||||||
import { bus } from "sst/aws/bus";
|
import { bus } from "sst/aws/bus";
|
||||||
import { Common } from "../common";
|
import { Common } from "../common";
|
||||||
import { createID, fn } from "../utils";
|
|
||||||
import { Examples } from "../examples";
|
import { Examples } from "../examples";
|
||||||
import { teamTable } from "./team.sql";
|
|
||||||
import { createEvent } from "../event";
|
import { createEvent } from "../event";
|
||||||
import { assertActor, withActor } from "../actor";
|
import { createID, fn } from "../utils";
|
||||||
import { and, eq, sql } from "../drizzle";
|
import { and, eq, sql } from "../drizzle";
|
||||||
|
import { PlanType, teamTable } from "./team.sql";
|
||||||
|
import { assertActor, withActor } from "../actor";
|
||||||
import { memberTable } from "../member/member.sql";
|
import { memberTable } from "../member/member.sql";
|
||||||
import { HTTPException } from 'hono/http-exception';
|
import { ErrorCodes, VisibleError } from "../error";
|
||||||
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
|
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
|
||||||
|
|
||||||
export module Team {
|
export module Team {
|
||||||
@@ -19,13 +19,17 @@ export module Team {
|
|||||||
description: Common.IdDescription,
|
description: Common.IdDescription,
|
||||||
example: Examples.Team.id,
|
example: Examples.Team.id,
|
||||||
}),
|
}),
|
||||||
slug: z.string().openapi({
|
slug: z.string().regex(/^[a-z0-9\-]+$/, "Use a URL friendly name.").openapi({
|
||||||
description: "The unique and url-friendly slug of this team",
|
description: "The unique and url-friendly slug of this team",
|
||||||
example: Examples.Team.slug
|
example: Examples.Team.slug
|
||||||
}),
|
}),
|
||||||
name: z.string().openapi({
|
name: z.string().openapi({
|
||||||
description: "The name of this team",
|
description: "The name of this team",
|
||||||
example: Examples.Team.name
|
example: Examples.Team.name
|
||||||
|
}),
|
||||||
|
planType: z.enum(PlanType).openapi({
|
||||||
|
description: "The type of Plan this team is subscribed to",
|
||||||
|
example: Examples.Team.planType
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.openapi({
|
.openapi({
|
||||||
@@ -45,40 +49,36 @@ export module Team {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export class TeamExistsError extends HTTPException {
|
export class TeamExistsError extends VisibleError {
|
||||||
constructor(slug: string) {
|
constructor(slug: string) {
|
||||||
super(
|
super(
|
||||||
400,
|
"already_exists",
|
||||||
{ message: `There is already a team named "${slug}"`, }
|
ErrorCodes.Validation.TEAM_ALREADY_EXISTS,
|
||||||
|
`There is already a team named "${slug}"`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const create = fn(
|
export const create = fn(
|
||||||
Info.pick({ slug: true, id: true, name: true }).partial({
|
Info.pick({ slug: true, id: true, name: true, planType: true }).partial({
|
||||||
id: true,
|
id: true,
|
||||||
}), (input) => {
|
}), (input) =>
|
||||||
createTransaction(async (tx) => {
|
createTransaction(async (tx) => {
|
||||||
const id = input.id ?? createID("team");
|
const id = input.id ?? createID("team");
|
||||||
const result = await tx.insert(teamTable).values({
|
const result = await tx.insert(teamTable).values({
|
||||||
id,
|
id,
|
||||||
slug: input.slug,
|
//Remove spaces and make sure it is lowercase (this is just to make sure the frontend did this)
|
||||||
name: input.name
|
slug: input.slug, //.toLowerCase().replace(/[\s]/g, ''),
|
||||||
})
|
planType: input.planType,
|
||||||
.onConflictDoNothing({ target: teamTable.slug })
|
name: input.name
|
||||||
|
|
||||||
if (!result.rowCount) throw new TeamExistsError(input.slug);
|
|
||||||
|
|
||||||
await afterTx(() =>
|
|
||||||
withActor({ type: "system", properties: { teamID: id } }, () =>
|
|
||||||
bus.publish(Resource.Bus, Events.Created, {
|
|
||||||
teamID: id,
|
|
||||||
})
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return id;
|
|
||||||
})
|
})
|
||||||
|
.onConflictDoNothing({ target: teamTable.slug })
|
||||||
|
|
||||||
|
if (result.count === 0) throw new TeamExistsError(input.slug);
|
||||||
|
|
||||||
|
return id;
|
||||||
})
|
})
|
||||||
|
)
|
||||||
|
|
||||||
export const remove = fn(Info.shape.id, (input) =>
|
export const remove = fn(Info.shape.id, (input) =>
|
||||||
useTransaction(async (tx) => {
|
useTransaction(async (tx) => {
|
||||||
@@ -147,6 +147,7 @@ export module Team {
|
|||||||
id: input.id,
|
id: input.id,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
slug: input.slug,
|
slug: input.slug,
|
||||||
|
planType: input.planType,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,27 @@
|
|||||||
import {} from "drizzle-orm/postgres-js";
|
import { } from "drizzle-orm/postgres-js";
|
||||||
import { timestamps, id } from "../drizzle/types";
|
import { timestamps, id } from "../drizzle/types";
|
||||||
import {
|
import {
|
||||||
|
varchar,
|
||||||
pgTable,
|
pgTable,
|
||||||
primaryKey,
|
primaryKey,
|
||||||
uniqueIndex,
|
uniqueIndex,
|
||||||
varchar,
|
text
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
export const PlanType = ["Hosted", "BYOG"] as const;
|
||||||
|
|
||||||
export const teamTable = pgTable(
|
export const teamTable = pgTable(
|
||||||
"team",
|
"team",
|
||||||
{
|
{
|
||||||
...id,
|
...id,
|
||||||
...timestamps,
|
...timestamps,
|
||||||
slug: varchar("slug", { length: 255 }).notNull(),
|
|
||||||
name: varchar("name", { length: 255 }).notNull(),
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
|
slug: varchar("slug", { length: 255 }).notNull(),
|
||||||
|
planType: text("plan_type", { enum: PlanType }).notNull()
|
||||||
},
|
},
|
||||||
(table) => [uniqueIndex("slug").on(table.slug)],
|
(table) => [
|
||||||
|
uniqueIndex("slug").on(table.slug)
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
export function teamIndexes(table: any) {
|
export function teamIndexes(table: any) {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Polar } from "../polar";
|
|
||||||
import { Team } from "../team";
|
import { Team } from "../team";
|
||||||
import { bus } from "sst/aws/bus";
|
import { bus } from "sst/aws/bus";
|
||||||
import { Common } from "../common";
|
import { Common } from "../common";
|
||||||
|
import { Polar } from "../polar/index";
|
||||||
import { createID, fn } from "../utils";
|
import { createID, fn } from "../utils";
|
||||||
import { userTable } from "./user.sql";
|
import { userTable } from "./user.sql";
|
||||||
import { createEvent } from "../event";
|
import { createEvent } from "../event";
|
||||||
@@ -188,7 +188,7 @@ export module User {
|
|||||||
await tx
|
await tx
|
||||||
.update(userTable)
|
.update(userTable)
|
||||||
.set({
|
.set({
|
||||||
timeDeleted: sql`CURRENT_TIMESTAMP()`,
|
timeDeleted: sql`now()`,
|
||||||
})
|
})
|
||||||
.where(and(eq(userTable.id, input)))
|
.where(and(eq(userTable.id, input)))
|
||||||
.execute();
|
.execute();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { id, timestamps } from "../drizzle/types";
|
import { id, timestamps } from "../drizzle/types";
|
||||||
import { integer, pgTable, text, uniqueIndex, varchar,json } from "drizzle-orm/pg-core";
|
import { integer, pgTable, text, uniqueIndex, varchar, json } from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
// Whether this user is part of the Nestri Team, comes with privileges
|
// Whether this user is part of the Nestri Team, comes with privileges
|
||||||
export const UserFlags = z.object({
|
export const UserFlags = z.object({
|
||||||
@@ -15,13 +15,13 @@ export const userTable = pgTable(
|
|||||||
...id,
|
...id,
|
||||||
...timestamps,
|
...timestamps,
|
||||||
avatarUrl: text("avatar_url"),
|
avatarUrl: text("avatar_url"),
|
||||||
email: varchar("email", { length: 255 }).notNull(),
|
|
||||||
name: varchar("name", { length: 255 }).notNull(),
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
discriminator: integer("discriminator").notNull(),
|
discriminator: integer("discriminator").notNull(),
|
||||||
|
email: varchar("email", { length: 255 }).notNull(),
|
||||||
polarCustomerID: varchar("polar_customer_id", { length: 255 }).unique(),
|
polarCustomerID: varchar("polar_customer_id", { length: 255 }).unique(),
|
||||||
flags: json("flags").$type<UserFlags>().default({}),
|
flags: json("flags").$type<UserFlags>().default({}),
|
||||||
},
|
},
|
||||||
(user) => [
|
(user) => [
|
||||||
uniqueIndex("user_email").on(user.email),
|
uniqueIndex("user_email").on(user.email),
|
||||||
],
|
]
|
||||||
);
|
);
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
{
|
{
|
||||||
"extends": "@tsconfig/node20/tsconfig.json",
|
"extends": "@tsconfig/node20/tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"jsx": "react-jsx",
|
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"noUncheckedIndexedAccess": true,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "@nestri/functions",
|
"name": "@nestri/functions",
|
||||||
"module": "index.ts",
|
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
"./*": "./src/*.ts"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@aws-sdk/client-ecs": "^3.738.0",
|
"@aws-sdk/client-ecs": "^3.738.0",
|
||||||
"@aws-sdk/client-sqs": "^3.734.0",
|
"@aws-sdk/client-sqs": "^3.734.0",
|
||||||
@@ -14,9 +16,10 @@
|
|||||||
"typescript": "^5.0.0"
|
"typescript": "^5.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@openauthjs/openauth": "0.4.3",
|
"@openauthjs/openauth": "*",
|
||||||
"hono": "^4.6.15",
|
"hono": "^4.6.15",
|
||||||
"hono-openapi": "^0.3.1",
|
"hono-openapi": "^0.3.1",
|
||||||
"partysocket": "1.0.3"
|
"partysocket": "1.0.3",
|
||||||
|
"postgres": "^3.4.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { notPublic } from "./auth";
|
import { notPublic } from "./auth";
|
||||||
import { Result } from "../common";
|
|
||||||
import { resolver } from "hono-openapi/zod";
|
|
||||||
import { describeRoute } from "hono-openapi";
|
import { describeRoute } from "hono-openapi";
|
||||||
import { User } from "@nestri/core/user/index";
|
import { User } from "@nestri/core/user/index";
|
||||||
import { Team } from "@nestri/core/team/index";
|
import { Team } from "@nestri/core/team/index";
|
||||||
import { assertActor } from "@nestri/core/actor";
|
import { assertActor } from "@nestri/core/actor";
|
||||||
|
import { Examples } from "@nestri/core/examples";
|
||||||
|
import { ErrorResponses, Result } from "./common";
|
||||||
|
import { ErrorCodes, VisibleError } from "@nestri/core/error";
|
||||||
|
|
||||||
export module AccountApi {
|
export module AccountApi {
|
||||||
export const route = new Hono()
|
export const route = new Hono()
|
||||||
@@ -14,8 +15,8 @@ export module AccountApi {
|
|||||||
.get("/",
|
.get("/",
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ["Account"],
|
tags: ["Account"],
|
||||||
summary: "Retrieve the current user's details",
|
summary: "Get user account",
|
||||||
description: "Returns the user's account details, plus the teams they have joined",
|
description: "Get the current user's account details",
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
content: {
|
content: {
|
||||||
@@ -24,35 +25,36 @@ export module AccountApi {
|
|||||||
z.object({
|
z.object({
|
||||||
...User.Info.shape,
|
...User.Info.shape,
|
||||||
teams: Team.Info.array(),
|
teams: Team.Info.array(),
|
||||||
|
}).openapi({
|
||||||
|
description: "User account information",
|
||||||
|
example: { ...Examples.User, teams: [Examples.Team] }
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
description: "Successfully retrieved account details"
|
description: "User account details"
|
||||||
},
|
|
||||||
404: {
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: resolver(z.object({ error: z.string() })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
description: "This account does not exist",
|
|
||||||
},
|
},
|
||||||
|
404: ErrorResponses[404]
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const actor = assertActor("user");
|
const actor = assertActor("user");
|
||||||
const [currentUser, teams] = await Promise.all([User.fromID(actor.properties.userID), User.teams()])
|
const [currentUser, teams] = await Promise.all([User.fromID(actor.properties.userID), User.teams()])
|
||||||
|
|
||||||
if (!currentUser) return c.json({ error: "This account does not exist; it may have been deleted" }, 404)
|
if (!currentUser)
|
||||||
|
throw new VisibleError(
|
||||||
|
"not_found",
|
||||||
|
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||||
|
"User not found",
|
||||||
|
);
|
||||||
|
|
||||||
const { id, email, name, polarCustomerID, avatarUrl, discriminator } = currentUser
|
const { id, email, name, polarCustomerID, avatarUrl, discriminator } = currentUser
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
data: {
|
data: {
|
||||||
id,
|
id,
|
||||||
email,
|
|
||||||
name,
|
name,
|
||||||
|
email,
|
||||||
teams,
|
teams,
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
discriminator,
|
discriminator,
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { Resource } from "sst";
|
import { Resource } from "sst";
|
||||||
import { subjects } from "../subjects";
|
import { subjects } from "../subjects";
|
||||||
import { type MiddlewareHandler } from "hono";
|
import { type MiddlewareHandler } from "hono";
|
||||||
// import { User } from "@nestri/core/user/index";
|
|
||||||
import { VisibleError } from "@nestri/core/error";
|
|
||||||
import { HTTPException } from "hono/http-exception";
|
|
||||||
import { useActor, withActor } from "@nestri/core/actor";
|
import { useActor, withActor } from "@nestri/core/actor";
|
||||||
import { createClient } from "@openauthjs/openauth/client";
|
import { createClient } from "@openauthjs/openauth/client";
|
||||||
|
import { ErrorCodes, VisibleError } from "@nestri/core/error";
|
||||||
|
|
||||||
const client = createClient({
|
const client = createClient({
|
||||||
issuer: Resource.Urls.auth,
|
issuer: Resource.Urls.auth,
|
||||||
@@ -15,7 +13,11 @@ const client = createClient({
|
|||||||
export const notPublic: MiddlewareHandler = async (c, next) => {
|
export const notPublic: MiddlewareHandler = async (c, next) => {
|
||||||
const actor = useActor();
|
const actor = useActor();
|
||||||
if (actor.type === "public")
|
if (actor.type === "public")
|
||||||
throw new HTTPException(401, { message: "Unauthorized" });
|
throw new VisibleError(
|
||||||
|
"authentication",
|
||||||
|
ErrorCodes.Authentication.UNAUTHORIZED,
|
||||||
|
"Missing authorization header",
|
||||||
|
);
|
||||||
return next();
|
return next();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -26,16 +28,19 @@ export const auth: MiddlewareHandler = async (c, next) => {
|
|||||||
const match = authHeader.match(/^Bearer (.+)$/);
|
const match = authHeader.match(/^Bearer (.+)$/);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
throw new VisibleError(
|
throw new VisibleError(
|
||||||
"auth.token",
|
"authentication",
|
||||||
"Bearer token not found or improperly formatted",
|
ErrorCodes.Authentication.INVALID_TOKEN,
|
||||||
|
"Invalid personal access token",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const bearerToken = match[1];
|
const bearerToken = match[1];
|
||||||
let result = await client.verify(subjects, bearerToken!);
|
let result = await client.verify(subjects, bearerToken!);
|
||||||
if (result.err) {
|
if (result.err) {
|
||||||
throw new HTTPException(401, {
|
throw new VisibleError(
|
||||||
message: "Unauthorized",
|
"authentication",
|
||||||
});
|
ErrorCodes.Authentication.INVALID_TOKEN,
|
||||||
|
"Invalid bearer token",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.subject.type === "user") {
|
if (result.subject.type === "user") {
|
||||||
@@ -50,20 +55,20 @@ export const auth: MiddlewareHandler = async (c, next) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
next
|
next
|
||||||
// async () => {
|
// async () => {
|
||||||
// const user = await User.fromEmail(email);
|
// const user = await User.fromEmail(email);
|
||||||
// if (!user || user.length === 0) {
|
// if (!user || user.length === 0) {
|
||||||
// c.status(401);
|
// c.status(401);
|
||||||
// return c.text("Unauthorized");
|
// return c.text("Unauthorized");
|
||||||
// }
|
// }
|
||||||
// return withActor(
|
// return withActor(
|
||||||
// {
|
// {
|
||||||
// type: "member",
|
// type: "member",
|
||||||
// properties: { userID: user[0].id, workspaceID: user.workspaceID },
|
// properties: { userID: user[0].id, workspaceID: user.workspaceID },
|
||||||
// },
|
// },
|
||||||
// next,
|
// next,
|
||||||
// );
|
// );
|
||||||
// },
|
// },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
246
packages/functions/src/api/common.ts
Normal file
246
packages/functions/src/api/common.ts
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import {type Hook } from "./hook";
|
||||||
|
import { z, ZodSchema } from "zod";
|
||||||
|
import { ErrorCodes, ErrorResponse } from "@nestri/core/error";
|
||||||
|
import type { MiddlewareHandler, ValidationTargets } from "hono";
|
||||||
|
import { resolver, validator as zodValidator } from "hono-openapi/zod";
|
||||||
|
|
||||||
|
export function Result<T extends z.ZodTypeAny>(schema: T) {
|
||||||
|
return resolver(
|
||||||
|
z.object({
|
||||||
|
data: schema,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom validator wrapper around hono-openapi/zod validator that formats errors
|
||||||
|
* according to our standard API error format
|
||||||
|
*/
|
||||||
|
export const validator = <
|
||||||
|
T extends ZodSchema,
|
||||||
|
Target extends keyof ValidationTargets
|
||||||
|
>(
|
||||||
|
target: Target,
|
||||||
|
schema: T
|
||||||
|
): MiddlewareHandler<
|
||||||
|
any,
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
in: {
|
||||||
|
[K in Target]: z.input<T>;
|
||||||
|
};
|
||||||
|
out: {
|
||||||
|
[K in Target]: z.output<T>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
> => {
|
||||||
|
// Create a custom error handler that formats errors according to our standards
|
||||||
|
// const standardErrorHandler: Parameters<typeof zodValidator>[2] = (
|
||||||
|
const standardErrorHandler: Hook<z.infer<T>, any, any, Target> = (
|
||||||
|
result,
|
||||||
|
c,
|
||||||
|
) => {
|
||||||
|
if (!result.success) {
|
||||||
|
// Get the validation issues
|
||||||
|
const issues = result.error.issues || result.error.errors || [];
|
||||||
|
if (issues.length === 0) {
|
||||||
|
// If there are no issues, return a generic error
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
type: "validation",
|
||||||
|
code: ErrorCodes.Validation.INVALID_PARAMETER,
|
||||||
|
message: "Invalid request data",
|
||||||
|
},
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the first error for the main response
|
||||||
|
const firstIssue = issues[0]!;
|
||||||
|
const fieldPath = firstIssue.path
|
||||||
|
? Array.isArray(firstIssue.path)
|
||||||
|
? firstIssue.path.join(".")
|
||||||
|
: firstIssue.path
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Map Zod error codes to our standard error codes
|
||||||
|
let errorCode = ErrorCodes.Validation.INVALID_PARAMETER;
|
||||||
|
if (
|
||||||
|
firstIssue.code === "invalid_type" &&
|
||||||
|
firstIssue.received === "undefined"
|
||||||
|
) {
|
||||||
|
errorCode = ErrorCodes.Validation.MISSING_REQUIRED_FIELD;
|
||||||
|
} else if (
|
||||||
|
["invalid_string", "invalid_date", "invalid_regex"].includes(
|
||||||
|
firstIssue.code,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
errorCode = ErrorCodes.Validation.INVALID_FORMAT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create our standardized error response
|
||||||
|
const response = {
|
||||||
|
type: "validation",
|
||||||
|
code: errorCode,
|
||||||
|
message: firstIssue.message,
|
||||||
|
param: fieldPath,
|
||||||
|
details: undefined as any,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add details if we have multiple issues
|
||||||
|
if (issues.length > 0) {
|
||||||
|
response.details = {
|
||||||
|
issues: issues.map((issue) => ({
|
||||||
|
path: issue.path
|
||||||
|
? Array.isArray(issue.path)
|
||||||
|
? issue.path.join(".")
|
||||||
|
: issue.path
|
||||||
|
: undefined,
|
||||||
|
code: issue.code,
|
||||||
|
message: issue.message,
|
||||||
|
// @ts-expect-error
|
||||||
|
expected: issue.expected,
|
||||||
|
// @ts-expect-error
|
||||||
|
received: issue.received,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Validation error in validator:", response);
|
||||||
|
return c.json(response, 400);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use the original validator with our custom error handler
|
||||||
|
return zodValidator(target, schema, standardErrorHandler);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard error responses for OpenAPI documentation
|
||||||
|
*/
|
||||||
|
export const ErrorResponses = {
|
||||||
|
400: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(
|
||||||
|
ErrorResponse.openapi({
|
||||||
|
description: "Validation error",
|
||||||
|
example: {
|
||||||
|
type: "validation",
|
||||||
|
code: "invalid_parameter",
|
||||||
|
message: "The request was invalid",
|
||||||
|
param: "email",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
description:
|
||||||
|
"Bad Request - The request could not be understood or was missing required parameters.",
|
||||||
|
},
|
||||||
|
401: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(
|
||||||
|
ErrorResponse.openapi({
|
||||||
|
description: "Authentication error",
|
||||||
|
example: {
|
||||||
|
type: "authentication",
|
||||||
|
code: "unauthorized",
|
||||||
|
message: "Authentication required",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
description:
|
||||||
|
"Unauthorized - Authentication is required and has failed or has not been provided.",
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(
|
||||||
|
ErrorResponse.openapi({
|
||||||
|
description: "Permission error",
|
||||||
|
example: {
|
||||||
|
type: "forbidden",
|
||||||
|
code: "permission_denied",
|
||||||
|
message: "You do not have permission to access this resource",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
description:
|
||||||
|
"Forbidden - You do not have permission to access this resource.",
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(
|
||||||
|
ErrorResponse.openapi({
|
||||||
|
description: "Not found error",
|
||||||
|
example: {
|
||||||
|
type: "not_found",
|
||||||
|
code: "resource_not_found",
|
||||||
|
message: "The requested resource could not be found",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
description: "Not Found - The requested resource does not exist.",
|
||||||
|
},
|
||||||
|
409: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(
|
||||||
|
ErrorResponse.openapi({
|
||||||
|
description: "Conflict Error",
|
||||||
|
example: {
|
||||||
|
type: "already_exists",
|
||||||
|
code: "resource_already_exists",
|
||||||
|
message: "The resource could not be created because it already exists",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
description: "Conflict - The resource could not be created because it already exists.",
|
||||||
|
},
|
||||||
|
429: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(
|
||||||
|
ErrorResponse.openapi({
|
||||||
|
description: "Rate limit error",
|
||||||
|
example: {
|
||||||
|
type: "rate_limit",
|
||||||
|
code: "too_many_requests",
|
||||||
|
message: "Rate limit exceeded",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
description:
|
||||||
|
"Too Many Requests - You have made too many requests in a short period of time.",
|
||||||
|
},
|
||||||
|
500: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(
|
||||||
|
ErrorResponse.openapi({
|
||||||
|
description: "Server error",
|
||||||
|
example: {
|
||||||
|
type: "internal",
|
||||||
|
code: "internal_error",
|
||||||
|
message: "Internal server error",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
description: "Internal Server Error - Something went wrong on our end.",
|
||||||
|
},
|
||||||
|
};
|
||||||
20
packages/functions/src/api/hook.ts
Normal file
20
packages/functions/src/api/hook.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { ZodError, ZodSchema, z } from 'zod';
|
||||||
|
import type { Env, ValidationTargets, Context, TypedResponse, Input, MiddlewareHandler } from 'hono';
|
||||||
|
|
||||||
|
type Hook<T, E extends Env, P extends string, Target extends keyof ValidationTargets = keyof ValidationTargets, O = {}> = (result: ({
|
||||||
|
success: true;
|
||||||
|
data: T;
|
||||||
|
} | {
|
||||||
|
success: false;
|
||||||
|
error: ZodError;
|
||||||
|
data: T;
|
||||||
|
}) & {
|
||||||
|
target: Target;
|
||||||
|
}, c: Context<E, P>) => Response | void | TypedResponse<O> | Promise<Response | void | TypedResponse<O>>;
|
||||||
|
type HasUndefined<T> = undefined extends T ? true : false;
|
||||||
|
declare const zValidator: <T extends ZodSchema<any, z.ZodTypeDef, any>, Target extends keyof ValidationTargets, E extends Env, P extends string, In = z.input<T>, Out = z.output<T>, I extends Input = {
|
||||||
|
in: HasUndefined<In> extends true ? { [K in Target]?: (In extends ValidationTargets[K] ? In : { [K2 in keyof In]?: ValidationTargets[K][K2] | undefined; }) | undefined; } : { [K_1 in Target]: In extends ValidationTargets[K_1] ? In : { [K2_1 in keyof In]: ValidationTargets[K_1][K2_1]; }; };
|
||||||
|
out: { [K_2 in Target]: Out; };
|
||||||
|
}, V extends I = I>(target: Target, schema: T, hook?: Hook<z.TypeOf<T>, E, P, Target, {}> | undefined) => MiddlewareHandler<E, P, V>;
|
||||||
|
|
||||||
|
export { type Hook, zValidator };
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
import "zod-openapi/extend";
|
import "zod-openapi/extend";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { auth } from "./auth";
|
import { auth } from "./auth";
|
||||||
import { ZodError } from "zod";
|
import { TeamApi } from "./team";
|
||||||
import { logger } from "hono/logger";
|
import { logger } from "hono/logger";
|
||||||
import { AccountApi } from "./account";
|
import { AccountApi } from "./account";
|
||||||
import { openAPISpecs } from "hono-openapi";
|
import { openAPISpecs } from "hono-openapi";
|
||||||
import { VisibleError } from "@nestri/core/error";
|
|
||||||
import { HTTPException } from "hono/http-exception";
|
import { HTTPException } from "hono/http-exception";
|
||||||
import { handle, streamHandle } from "hono/aws-lambda";
|
import { handle, streamHandle } from "hono/aws-lambda";
|
||||||
|
import { ErrorCodes, VisibleError } from "@nestri/core/error";
|
||||||
|
|
||||||
|
|
||||||
const app = new Hono();
|
export const app = new Hono();
|
||||||
app
|
app
|
||||||
.use(logger(), async (c, next) => {
|
.use(logger(), async (c, next) => {
|
||||||
c.header("Cache-Control", "no-store");
|
c.header("Cache-Control", "no-store");
|
||||||
@@ -20,57 +20,47 @@ app
|
|||||||
|
|
||||||
const routes = app
|
const routes = app
|
||||||
.get("/", (c) => c.text("Hello World!"))
|
.get("/", (c) => c.text("Hello World!"))
|
||||||
|
.route("/team", TeamApi.route)
|
||||||
.route("/account", AccountApi.route)
|
.route("/account", AccountApi.route)
|
||||||
.onError((error, c) => {
|
.onError((error, c) => {
|
||||||
console.warn(error);
|
console.warn(error);
|
||||||
if (error instanceof VisibleError) {
|
if (error instanceof VisibleError) {
|
||||||
return c.json(
|
console.error("api error:", error);
|
||||||
{
|
// @ts-expect-error
|
||||||
code: error.code,
|
return c.json(error.toResponse(), error.statusCode());
|
||||||
message: error.message,
|
|
||||||
},
|
|
||||||
400
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (error instanceof ZodError) {
|
|
||||||
const e = error.errors[0];
|
|
||||||
if (e) {
|
|
||||||
return c.json(
|
|
||||||
{
|
|
||||||
code: e?.code,
|
|
||||||
message: e?.message,
|
|
||||||
},
|
|
||||||
400,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// Handle HTTP exceptions
|
||||||
if (error instanceof HTTPException) {
|
if (error instanceof HTTPException) {
|
||||||
|
console.error("http error:", error);
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
code: "request",
|
type: "validation",
|
||||||
|
code: ErrorCodes.Validation.INVALID_PARAMETER,
|
||||||
message: "Invalid request",
|
message: "Invalid request",
|
||||||
},
|
},
|
||||||
400,
|
400,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
console.error("unhandled error:", error);
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
code: "internal",
|
type: "internal",
|
||||||
|
code: ErrorCodes.Server.INTERNAL_ERROR,
|
||||||
message: "Internal server error",
|
message: "Internal server error",
|
||||||
},
|
},
|
||||||
500,
|
500,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
app.get(
|
app.get(
|
||||||
"/doc",
|
"/doc",
|
||||||
openAPISpecs(routes, {
|
openAPISpecs(routes, {
|
||||||
documentation: {
|
documentation: {
|
||||||
info: {
|
info: {
|
||||||
title: "Nestri API",
|
title: "Nestri API",
|
||||||
description:
|
description: "The Nestri API gives you the power to run your own customized cloud gaming platform.",
|
||||||
"The Nestri API gives you the power to run your own customized cloud gaming platform.",
|
version: "0.0.1",
|
||||||
version: "0.3.0",
|
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
securitySchemes: {
|
securitySchemes: {
|
||||||
@@ -81,13 +71,13 @@ app.get(
|
|||||||
},
|
},
|
||||||
TeamID: {
|
TeamID: {
|
||||||
type: "apiKey",
|
type: "apiKey",
|
||||||
description:"The team ID to use for this query",
|
description: "The team ID to use for this query",
|
||||||
in: "header",
|
in: "header",
|
||||||
name: "x-nestri-team"
|
name: "x-nestri-team"
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
security: [{ Bearer: [], TeamID:[] }],
|
security: [{ Bearer: [], TeamID: [] }],
|
||||||
servers: [
|
servers: [
|
||||||
{ description: "Production", url: "https://api.nestri.io" },
|
{ description: "Production", url: "https://api.nestri.io" },
|
||||||
],
|
],
|
||||||
|
|||||||
95
packages/functions/src/api/team.ts
Normal file
95
packages/functions/src/api/team.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { notPublic } from "./auth";
|
||||||
|
import { describeRoute } from "hono-openapi";
|
||||||
|
import { User } from "@nestri/core/user/index";
|
||||||
|
import { Team } from "@nestri/core/team/index";
|
||||||
|
import { Examples } from "@nestri/core/examples";
|
||||||
|
import { Member } from "@nestri/core/member/index";
|
||||||
|
import { assertActor, withActor } from "@nestri/core/actor";
|
||||||
|
import { ErrorResponses, Result, validator } from "./common";
|
||||||
|
|
||||||
|
export module TeamApi {
|
||||||
|
export const route = new Hono()
|
||||||
|
.use(notPublic)
|
||||||
|
.get("/",
|
||||||
|
describeRoute({
|
||||||
|
tags: ["Team"],
|
||||||
|
summary: "List teams",
|
||||||
|
description: "List the teams associated with the current user",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: Result(
|
||||||
|
Team.Info.array().openapi({
|
||||||
|
description: "List of teams",
|
||||||
|
example: [Examples.Team]
|
||||||
|
})
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
description: "List of teams"
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
return c.json({
|
||||||
|
data: await User.teams()
|
||||||
|
}, 200);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.post("/",
|
||||||
|
describeRoute({
|
||||||
|
tags: ["Team"],
|
||||||
|
summary: "Create a team",
|
||||||
|
description: "Create a team for the current user",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: Result(
|
||||||
|
z.literal("ok")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
description: "Team created succesfully"
|
||||||
|
},
|
||||||
|
400: ErrorResponses[400],
|
||||||
|
409: ErrorResponses[409],
|
||||||
|
429: ErrorResponses[429],
|
||||||
|
500: ErrorResponses[500],
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
validator(
|
||||||
|
"json",
|
||||||
|
Team.create.schema.omit({ id: true }).openapi({
|
||||||
|
description: "Details of the team to create",
|
||||||
|
//@ts-expect-error
|
||||||
|
example: { ...Examples.Team, id: undefined }
|
||||||
|
})
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const body = c.req.valid("json")
|
||||||
|
const actor = assertActor("user");
|
||||||
|
|
||||||
|
const teamID = await Team.create(body);
|
||||||
|
|
||||||
|
await withActor(
|
||||||
|
{
|
||||||
|
type: "system",
|
||||||
|
properties: {
|
||||||
|
teamID,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
() =>
|
||||||
|
Member.create({
|
||||||
|
first: true,
|
||||||
|
email: actor.properties.email,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json({ data: "ok" })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -136,12 +136,17 @@ const app = issuer({
|
|||||||
return ctx.subject("user", {
|
return ctx.subject("user", {
|
||||||
userID,
|
userID,
|
||||||
email
|
email
|
||||||
|
}, {
|
||||||
|
subject: email
|
||||||
});
|
});
|
||||||
|
|
||||||
} else if (matching) {
|
} else if (matching) {
|
||||||
//Sign In
|
//Sign In
|
||||||
return ctx.subject("user", {
|
return ctx.subject("user", {
|
||||||
userID: matching.id,
|
userID: matching.id,
|
||||||
email
|
email
|
||||||
|
}, {
|
||||||
|
subject: email
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -175,12 +180,16 @@ const app = issuer({
|
|||||||
return ctx.subject("user", {
|
return ctx.subject("user", {
|
||||||
userID,
|
userID,
|
||||||
email: user.primary.email
|
email: user.primary.email
|
||||||
|
}, {
|
||||||
|
subject: user.primary.email
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
//Sign In
|
//Sign In
|
||||||
return await ctx.subject("user", {
|
return await ctx.subject("user", {
|
||||||
userID: matching.id,
|
userID: matching.id,
|
||||||
email: user.primary.email
|
email: user.primary.email
|
||||||
|
}, {
|
||||||
|
subject: user.primary.email
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import { z } from "zod";
|
|
||||||
import { resolver } from "hono-openapi/zod";
|
|
||||||
|
|
||||||
export function Result<T extends z.ZodTypeAny>(schema: T) {
|
|
||||||
return resolver(
|
|
||||||
z.object({
|
|
||||||
data: schema,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
export class VisibleError extends Error {
|
|
||||||
constructor(
|
|
||||||
public kind: "input" | "auth",
|
|
||||||
public code: string,
|
|
||||||
public message: string,
|
|
||||||
) {
|
|
||||||
super(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
8
packages/functions/src/migrator.ts
Normal file
8
packages/functions/src/migrator.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { db } from "@nestri/core/drizzle/index";
|
||||||
|
import { migrate } from "drizzle-orm/postgres-js/migrator";
|
||||||
|
|
||||||
|
export const handler = async (event: any) => {
|
||||||
|
await migrate(db, {
|
||||||
|
migrationsFolder: "./migrations",
|
||||||
|
});
|
||||||
|
};
|
||||||
10
packages/functions/src/zero.ts
Normal file
10
packages/functions/src/zero.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
// import postgres from "postgres";
|
||||||
|
import { db, sql } from "@nestri/core/drizzle/index";
|
||||||
|
|
||||||
|
export async function handler() {
|
||||||
|
// const sql = postgres(process.env.ZERO_UPSTREAM_DB!);
|
||||||
|
const perms = fs.readFileSync(".permissions.sql", "utf8");
|
||||||
|
// await sql.unsafe(perms);
|
||||||
|
await db.execute(sql.raw(perms))
|
||||||
|
}
|
||||||
37
packages/functions/sst-env.d.ts
vendored
37
packages/functions/sst-env.d.ts
vendored
@@ -35,6 +35,10 @@ declare module "sst" {
|
|||||||
"type": "sst.sst.Linkable"
|
"type": "sst.sst.Linkable"
|
||||||
"user": string
|
"user": string
|
||||||
}
|
}
|
||||||
|
"DatabaseMigrator": {
|
||||||
|
"name": string
|
||||||
|
"type": "sst.aws.Function"
|
||||||
|
}
|
||||||
"DiscordClientID": {
|
"DiscordClientID": {
|
||||||
"type": "sst.sst.Secret"
|
"type": "sst.sst.Secret"
|
||||||
"value": string
|
"value": string
|
||||||
@@ -56,10 +60,34 @@ declare module "sst" {
|
|||||||
"sender": string
|
"sender": string
|
||||||
"type": "sst.aws.Email"
|
"type": "sst.aws.Email"
|
||||||
}
|
}
|
||||||
|
"NestriVpc": {
|
||||||
|
"bastion": string
|
||||||
|
"type": "sst.aws.Vpc"
|
||||||
|
}
|
||||||
"PolarSecret": {
|
"PolarSecret": {
|
||||||
"type": "sst.sst.Secret"
|
"type": "sst.sst.Secret"
|
||||||
"value": string
|
"value": string
|
||||||
}
|
}
|
||||||
|
"Postgres": {
|
||||||
|
"clusterArn": string
|
||||||
|
"database": string
|
||||||
|
"host": string
|
||||||
|
"password": string
|
||||||
|
"port": number
|
||||||
|
"reader": string
|
||||||
|
"secretArn": string
|
||||||
|
"type": "sst.aws.Aurora"
|
||||||
|
"username": string
|
||||||
|
}
|
||||||
|
"Steam": {
|
||||||
|
"service": string
|
||||||
|
"type": "sst.aws.Service"
|
||||||
|
"url": string
|
||||||
|
}
|
||||||
|
"Storage": {
|
||||||
|
"name": string
|
||||||
|
"type": "sst.aws.Bucket"
|
||||||
|
}
|
||||||
"Urls": {
|
"Urls": {
|
||||||
"api": string
|
"api": string
|
||||||
"auth": string
|
"auth": string
|
||||||
@@ -70,6 +98,15 @@ declare module "sst" {
|
|||||||
"type": "sst.aws.StaticSite"
|
"type": "sst.aws.StaticSite"
|
||||||
"url": string
|
"url": string
|
||||||
}
|
}
|
||||||
|
"Zero": {
|
||||||
|
"service": string
|
||||||
|
"type": "sst.aws.Service"
|
||||||
|
"url": string
|
||||||
|
}
|
||||||
|
"ZeroPermissions": {
|
||||||
|
"name": string
|
||||||
|
"type": "sst.aws.Function"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
24
packages/scripts/src/db-reset.sh
Executable file
24
packages/scripts/src/db-reset.sh
Executable file
@@ -0,0 +1,24 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
database=$(echo $SST_RESOURCE_Postgres | jq -r '.database')
|
||||||
|
clusterArn=$(echo $SST_RESOURCE_Postgres | jq -r '.clusterArn')
|
||||||
|
secretArn=$(echo $SST_RESOURCE_Postgres | jq -r '.secretArn')
|
||||||
|
|
||||||
|
sql=$(cat <<-'STMT'
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
row record;
|
||||||
|
BEGIN
|
||||||
|
FOR row IN SELECT * FROM pg_tables WHERE schemaname = 'public' OR schemaname = 'drizzle'
|
||||||
|
LOOP
|
||||||
|
EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(row.tablename) || ' CASCADE';
|
||||||
|
EXECUTE 'DROP TABLE IF EXISTS drizzle.' || quote_ident(row.tablename) || ' CASCADE';
|
||||||
|
END LOOP;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
STMT
|
||||||
|
)
|
||||||
|
|
||||||
|
response=$(aws rds-data execute-statement --resource-arn $clusterArn --secret-arn $secretArn --database $database --sql "$sql" --format-records-as JSON)
|
||||||
|
json=$(echo $response | jq -r '.formattedRecords')
|
||||||
|
echo "$json" | jq .
|
||||||
10
packages/scripts/src/psql.sh
Executable file
10
packages/scripts/src/psql.sh
Executable file
@@ -0,0 +1,10 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
database=$(echo $SST_RESOURCE_Postgres | jq -r '.database')
|
||||||
|
clusterArn=$(echo $SST_RESOURCE_Postgres | jq -r '.clusterArn')
|
||||||
|
secretArn=$(echo $SST_RESOURCE_Postgres | jq -r '.secretArn')
|
||||||
|
|
||||||
|
sql="$@"
|
||||||
|
response=$(aws rds-data execute-statement --resource-arn $clusterArn --secret-arn $secretArn --database $database --sql "$sql" --format-records-as JSON)
|
||||||
|
json=$(echo $response | jq -r '.formattedRecords')
|
||||||
|
echo "$json" | jq .
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
#!/usr/bin/env bun
|
|
||||||
|
|
||||||
import { Resource } from "sst";
|
|
||||||
import { spawnSync } from "bun";
|
|
||||||
|
|
||||||
spawnSync(
|
|
||||||
[
|
|
||||||
"psql",
|
|
||||||
`postgresql://${Resource.Database.user}:${Resource.Database.password}@${Resource.Database.host}/${Resource.Database.name}?sslmode=require`,
|
|
||||||
],
|
|
||||||
{
|
|
||||||
stdout: "inherit",
|
|
||||||
stdin: "inherit",
|
|
||||||
stderr: "inherit",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
484
packages/steam/.gitignore
vendored
Normal file
484
packages/steam/.gitignore
vendored
Normal file
@@ -0,0 +1,484 @@
|
|||||||
|
## Ignore Visual Studio temporary files, build results, and
|
||||||
|
## files generated by popular Visual Studio add-ons.
|
||||||
|
##
|
||||||
|
## Get latest from `dotnet new gitignore`
|
||||||
|
|
||||||
|
# dotenv files
|
||||||
|
.env
|
||||||
|
|
||||||
|
# User-specific files
|
||||||
|
*.rsuser
|
||||||
|
*.suo
|
||||||
|
*.user
|
||||||
|
*.userosscache
|
||||||
|
*.sln.docstates
|
||||||
|
|
||||||
|
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||||
|
*.userprefs
|
||||||
|
|
||||||
|
# Mono auto generated files
|
||||||
|
mono_crash.*
|
||||||
|
|
||||||
|
# Build results
|
||||||
|
[Dd]ebug/
|
||||||
|
[Dd]ebugPublic/
|
||||||
|
[Rr]elease/
|
||||||
|
[Rr]eleases/
|
||||||
|
x64/
|
||||||
|
x86/
|
||||||
|
[Ww][Ii][Nn]32/
|
||||||
|
[Aa][Rr][Mm]/
|
||||||
|
[Aa][Rr][Mm]64/
|
||||||
|
bld/
|
||||||
|
[Bb]in/
|
||||||
|
[Oo]bj/
|
||||||
|
[Ll]og/
|
||||||
|
[Ll]ogs/
|
||||||
|
|
||||||
|
# Visual Studio 2015/2017 cache/options directory
|
||||||
|
.vs/
|
||||||
|
# Uncomment if you have tasks that create the project's static files in wwwroot
|
||||||
|
#wwwroot/
|
||||||
|
|
||||||
|
# Visual Studio 2017 auto generated files
|
||||||
|
Generated\ Files/
|
||||||
|
|
||||||
|
# MSTest test Results
|
||||||
|
[Tt]est[Rr]esult*/
|
||||||
|
[Bb]uild[Ll]og.*
|
||||||
|
|
||||||
|
# NUnit
|
||||||
|
*.VisualState.xml
|
||||||
|
TestResult.xml
|
||||||
|
nunit-*.xml
|
||||||
|
|
||||||
|
# Build Results of an ATL Project
|
||||||
|
[Dd]ebugPS/
|
||||||
|
[Rr]eleasePS/
|
||||||
|
dlldata.c
|
||||||
|
|
||||||
|
# Benchmark Results
|
||||||
|
BenchmarkDotNet.Artifacts/
|
||||||
|
|
||||||
|
# .NET
|
||||||
|
project.lock.json
|
||||||
|
project.fragment.lock.json
|
||||||
|
artifacts/
|
||||||
|
|
||||||
|
# Tye
|
||||||
|
.tye/
|
||||||
|
|
||||||
|
# ASP.NET Scaffolding
|
||||||
|
ScaffoldingReadMe.txt
|
||||||
|
|
||||||
|
# StyleCop
|
||||||
|
StyleCopReport.xml
|
||||||
|
|
||||||
|
# Files built by Visual Studio
|
||||||
|
*_i.c
|
||||||
|
*_p.c
|
||||||
|
*_h.h
|
||||||
|
*.ilk
|
||||||
|
*.meta
|
||||||
|
*.obj
|
||||||
|
*.iobj
|
||||||
|
*.pch
|
||||||
|
*.pdb
|
||||||
|
*.ipdb
|
||||||
|
*.pgc
|
||||||
|
*.pgd
|
||||||
|
*.rsp
|
||||||
|
*.sbr
|
||||||
|
*.tlb
|
||||||
|
*.tli
|
||||||
|
*.tlh
|
||||||
|
*.tmp
|
||||||
|
*.tmp_proj
|
||||||
|
*_wpftmp.csproj
|
||||||
|
*.log
|
||||||
|
*.tlog
|
||||||
|
*.vspscc
|
||||||
|
*.vssscc
|
||||||
|
.builds
|
||||||
|
*.pidb
|
||||||
|
*.svclog
|
||||||
|
*.scc
|
||||||
|
|
||||||
|
# Chutzpah Test files
|
||||||
|
_Chutzpah*
|
||||||
|
|
||||||
|
# Visual C++ cache files
|
||||||
|
ipch/
|
||||||
|
*.aps
|
||||||
|
*.ncb
|
||||||
|
*.opendb
|
||||||
|
*.opensdf
|
||||||
|
*.sdf
|
||||||
|
*.cachefile
|
||||||
|
*.VC.db
|
||||||
|
*.VC.VC.opendb
|
||||||
|
|
||||||
|
# Visual Studio profiler
|
||||||
|
*.psess
|
||||||
|
*.vsp
|
||||||
|
*.vspx
|
||||||
|
*.sap
|
||||||
|
|
||||||
|
# Visual Studio Trace Files
|
||||||
|
*.e2e
|
||||||
|
|
||||||
|
# TFS 2012 Local Workspace
|
||||||
|
$tf/
|
||||||
|
|
||||||
|
# Guidance Automation Toolkit
|
||||||
|
*.gpState
|
||||||
|
|
||||||
|
# ReSharper is a .NET coding add-in
|
||||||
|
_ReSharper*/
|
||||||
|
*.[Rr]e[Ss]harper
|
||||||
|
*.DotSettings.user
|
||||||
|
|
||||||
|
# TeamCity is a build add-in
|
||||||
|
_TeamCity*
|
||||||
|
|
||||||
|
# DotCover is a Code Coverage Tool
|
||||||
|
*.dotCover
|
||||||
|
|
||||||
|
# AxoCover is a Code Coverage Tool
|
||||||
|
.axoCover/*
|
||||||
|
!.axoCover/settings.json
|
||||||
|
|
||||||
|
# Coverlet is a free, cross platform Code Coverage Tool
|
||||||
|
coverage*.json
|
||||||
|
coverage*.xml
|
||||||
|
coverage*.info
|
||||||
|
|
||||||
|
# Visual Studio code coverage results
|
||||||
|
*.coverage
|
||||||
|
*.coveragexml
|
||||||
|
|
||||||
|
# NCrunch
|
||||||
|
_NCrunch_*
|
||||||
|
.*crunch*.local.xml
|
||||||
|
nCrunchTemp_*
|
||||||
|
|
||||||
|
# MightyMoose
|
||||||
|
*.mm.*
|
||||||
|
AutoTest.Net/
|
||||||
|
|
||||||
|
# Web workbench (sass)
|
||||||
|
.sass-cache/
|
||||||
|
|
||||||
|
# Installshield output folder
|
||||||
|
[Ee]xpress/
|
||||||
|
|
||||||
|
# DocProject is a documentation generator add-in
|
||||||
|
DocProject/buildhelp/
|
||||||
|
DocProject/Help/*.HxT
|
||||||
|
DocProject/Help/*.HxC
|
||||||
|
DocProject/Help/*.hhc
|
||||||
|
DocProject/Help/*.hhk
|
||||||
|
DocProject/Help/*.hhp
|
||||||
|
DocProject/Help/Html2
|
||||||
|
DocProject/Help/html
|
||||||
|
|
||||||
|
# Click-Once directory
|
||||||
|
publish/
|
||||||
|
|
||||||
|
# Publish Web Output
|
||||||
|
*.[Pp]ublish.xml
|
||||||
|
*.azurePubxml
|
||||||
|
# Note: Comment the next line if you want to checkin your web deploy settings,
|
||||||
|
# but database connection strings (with potential passwords) will be unencrypted
|
||||||
|
*.pubxml
|
||||||
|
*.publishproj
|
||||||
|
|
||||||
|
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
||||||
|
# checkin your Azure Web App publish settings, but sensitive information contained
|
||||||
|
# in these scripts will be unencrypted
|
||||||
|
PublishScripts/
|
||||||
|
|
||||||
|
# NuGet Packages
|
||||||
|
*.nupkg
|
||||||
|
# NuGet Symbol Packages
|
||||||
|
*.snupkg
|
||||||
|
# The packages folder can be ignored because of Package Restore
|
||||||
|
**/[Pp]ackages/*
|
||||||
|
# except build/, which is used as an MSBuild target.
|
||||||
|
!**/[Pp]ackages/build/
|
||||||
|
# Uncomment if necessary however generally it will be regenerated when needed
|
||||||
|
#!**/[Pp]ackages/repositories.config
|
||||||
|
# NuGet v3's project.json files produces more ignorable files
|
||||||
|
*.nuget.props
|
||||||
|
*.nuget.targets
|
||||||
|
|
||||||
|
# Microsoft Azure Build Output
|
||||||
|
csx/
|
||||||
|
*.build.csdef
|
||||||
|
|
||||||
|
# Microsoft Azure Emulator
|
||||||
|
ecf/
|
||||||
|
rcf/
|
||||||
|
|
||||||
|
# Windows Store app package directories and files
|
||||||
|
AppPackages/
|
||||||
|
BundleArtifacts/
|
||||||
|
Package.StoreAssociation.xml
|
||||||
|
_pkginfo.txt
|
||||||
|
*.appx
|
||||||
|
*.appxbundle
|
||||||
|
*.appxupload
|
||||||
|
|
||||||
|
# Visual Studio cache files
|
||||||
|
# files ending in .cache can be ignored
|
||||||
|
*.[Cc]ache
|
||||||
|
# but keep track of directories ending in .cache
|
||||||
|
!?*.[Cc]ache/
|
||||||
|
|
||||||
|
# Others
|
||||||
|
ClientBin/
|
||||||
|
~$*
|
||||||
|
*~
|
||||||
|
*.dbmdl
|
||||||
|
*.dbproj.schemaview
|
||||||
|
*.jfm
|
||||||
|
*.pfx
|
||||||
|
*.publishsettings
|
||||||
|
orleans.codegen.cs
|
||||||
|
|
||||||
|
# Including strong name files can present a security risk
|
||||||
|
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
|
||||||
|
#*.snk
|
||||||
|
|
||||||
|
# Since there are multiple workflows, uncomment next line to ignore bower_components
|
||||||
|
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
|
||||||
|
#bower_components/
|
||||||
|
|
||||||
|
# RIA/Silverlight projects
|
||||||
|
Generated_Code/
|
||||||
|
|
||||||
|
# Backup & report files from converting an old project file
|
||||||
|
# to a newer Visual Studio version. Backup files are not needed,
|
||||||
|
# because we have git ;-)
|
||||||
|
_UpgradeReport_Files/
|
||||||
|
Backup*/
|
||||||
|
UpgradeLog*.XML
|
||||||
|
UpgradeLog*.htm
|
||||||
|
ServiceFabricBackup/
|
||||||
|
*.rptproj.bak
|
||||||
|
|
||||||
|
# SQL Server files
|
||||||
|
*.mdf
|
||||||
|
*.ldf
|
||||||
|
*.ndf
|
||||||
|
|
||||||
|
# Business Intelligence projects
|
||||||
|
*.rdl.data
|
||||||
|
*.bim.layout
|
||||||
|
*.bim_*.settings
|
||||||
|
*.rptproj.rsuser
|
||||||
|
*- [Bb]ackup.rdl
|
||||||
|
*- [Bb]ackup ([0-9]).rdl
|
||||||
|
*- [Bb]ackup ([0-9][0-9]).rdl
|
||||||
|
|
||||||
|
# Microsoft Fakes
|
||||||
|
FakesAssemblies/
|
||||||
|
|
||||||
|
# GhostDoc plugin setting file
|
||||||
|
*.GhostDoc.xml
|
||||||
|
|
||||||
|
# Node.js Tools for Visual Studio
|
||||||
|
.ntvs_analysis.dat
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Visual Studio 6 build log
|
||||||
|
*.plg
|
||||||
|
|
||||||
|
# Visual Studio 6 workspace options file
|
||||||
|
*.opt
|
||||||
|
|
||||||
|
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
|
||||||
|
*.vbw
|
||||||
|
|
||||||
|
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
|
||||||
|
*.vbp
|
||||||
|
|
||||||
|
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
|
||||||
|
*.dsw
|
||||||
|
*.dsp
|
||||||
|
|
||||||
|
# Visual Studio 6 technical files
|
||||||
|
*.ncb
|
||||||
|
*.aps
|
||||||
|
|
||||||
|
# Visual Studio LightSwitch build output
|
||||||
|
**/*.HTMLClient/GeneratedArtifacts
|
||||||
|
**/*.DesktopClient/GeneratedArtifacts
|
||||||
|
**/*.DesktopClient/ModelManifest.xml
|
||||||
|
**/*.Server/GeneratedArtifacts
|
||||||
|
**/*.Server/ModelManifest.xml
|
||||||
|
_Pvt_Extensions
|
||||||
|
|
||||||
|
# Paket dependency manager
|
||||||
|
.paket/paket.exe
|
||||||
|
paket-files/
|
||||||
|
|
||||||
|
# FAKE - F# Make
|
||||||
|
.fake/
|
||||||
|
|
||||||
|
# CodeRush personal settings
|
||||||
|
.cr/personal
|
||||||
|
|
||||||
|
# Python Tools for Visual Studio (PTVS)
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# Cake - Uncomment if you are using it
|
||||||
|
# tools/**
|
||||||
|
# !tools/packages.config
|
||||||
|
|
||||||
|
# Tabs Studio
|
||||||
|
*.tss
|
||||||
|
|
||||||
|
# Telerik's JustMock configuration file
|
||||||
|
*.jmconfig
|
||||||
|
|
||||||
|
# BizTalk build output
|
||||||
|
*.btp.cs
|
||||||
|
*.btm.cs
|
||||||
|
*.odx.cs
|
||||||
|
*.xsd.cs
|
||||||
|
|
||||||
|
# OpenCover UI analysis results
|
||||||
|
OpenCover/
|
||||||
|
|
||||||
|
# Azure Stream Analytics local run output
|
||||||
|
ASALocalRun/
|
||||||
|
|
||||||
|
# MSBuild Binary and Structured Log
|
||||||
|
*.binlog
|
||||||
|
|
||||||
|
# NVidia Nsight GPU debugger configuration file
|
||||||
|
*.nvuser
|
||||||
|
|
||||||
|
# MFractors (Xamarin productivity tool) working folder
|
||||||
|
.mfractor/
|
||||||
|
|
||||||
|
# Local History for Visual Studio
|
||||||
|
.localhistory/
|
||||||
|
|
||||||
|
# Visual Studio History (VSHistory) files
|
||||||
|
.vshistory/
|
||||||
|
|
||||||
|
# BeatPulse healthcheck temp database
|
||||||
|
healthchecksdb
|
||||||
|
|
||||||
|
# Backup folder for Package Reference Convert tool in Visual Studio 2017
|
||||||
|
MigrationBackup/
|
||||||
|
|
||||||
|
# Ionide (cross platform F# VS Code tools) working folder
|
||||||
|
.ionide/
|
||||||
|
|
||||||
|
# Fody - auto-generated XML schema
|
||||||
|
FodyWeavers.xsd
|
||||||
|
|
||||||
|
# VS Code files for those working on multiple tools
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
*.code-workspace
|
||||||
|
|
||||||
|
# Local History for Visual Studio Code
|
||||||
|
.history/
|
||||||
|
|
||||||
|
# Windows Installer files from build outputs
|
||||||
|
*.cab
|
||||||
|
*.msi
|
||||||
|
*.msix
|
||||||
|
*.msm
|
||||||
|
*.msp
|
||||||
|
|
||||||
|
# JetBrains Rider
|
||||||
|
*.sln.iml
|
||||||
|
.idea
|
||||||
|
|
||||||
|
##
|
||||||
|
## Visual studio for Mac
|
||||||
|
##
|
||||||
|
|
||||||
|
|
||||||
|
# globs
|
||||||
|
Makefile.in
|
||||||
|
*.userprefs
|
||||||
|
*.usertasks
|
||||||
|
config.make
|
||||||
|
config.status
|
||||||
|
aclocal.m4
|
||||||
|
install-sh
|
||||||
|
autom4te.cache/
|
||||||
|
*.tar.gz
|
||||||
|
tarballs/
|
||||||
|
test-results/
|
||||||
|
|
||||||
|
# Mac bundle stuff
|
||||||
|
*.dmg
|
||||||
|
*.app
|
||||||
|
|
||||||
|
# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore
|
||||||
|
# General
|
||||||
|
.DS_Store
|
||||||
|
.AppleDouble
|
||||||
|
.LSOverride
|
||||||
|
|
||||||
|
# Icon must end with two \r
|
||||||
|
Icon
|
||||||
|
|
||||||
|
|
||||||
|
# Thumbnails
|
||||||
|
._*
|
||||||
|
|
||||||
|
# Files that might appear in the root of a volume
|
||||||
|
.DocumentRevisions-V100
|
||||||
|
.fseventsd
|
||||||
|
.Spotlight-V100
|
||||||
|
.TemporaryItems
|
||||||
|
.Trashes
|
||||||
|
.VolumeIcon.icns
|
||||||
|
.com.apple.timemachine.donotpresent
|
||||||
|
|
||||||
|
# Directories potentially created on remote AFP share
|
||||||
|
.AppleDB
|
||||||
|
.AppleDesktop
|
||||||
|
Network Trash Folder
|
||||||
|
Temporary Items
|
||||||
|
.apdisk
|
||||||
|
|
||||||
|
# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore
|
||||||
|
# Windows thumbnail cache files
|
||||||
|
Thumbs.db
|
||||||
|
ehthumbs.db
|
||||||
|
ehthumbs_vista.db
|
||||||
|
|
||||||
|
# Dump file
|
||||||
|
*.stackdump
|
||||||
|
|
||||||
|
# Folder config file
|
||||||
|
[Dd]esktop.ini
|
||||||
|
|
||||||
|
# Recycle Bin used on file shares
|
||||||
|
$RECYCLE.BIN/
|
||||||
|
|
||||||
|
# Windows Installer files
|
||||||
|
*.cab
|
||||||
|
*.msi
|
||||||
|
*.msix
|
||||||
|
*.msm
|
||||||
|
*.msp
|
||||||
|
|
||||||
|
# Windows shortcuts
|
||||||
|
*.lnk
|
||||||
|
|
||||||
|
# Vim temporary swap files
|
||||||
|
*.swp
|
||||||
18
packages/steam/DBContext.cs
Normal file
18
packages/steam/DBContext.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
public class SteamDbContext : DbContext
|
||||||
|
{
|
||||||
|
public DbSet<SteamUserCredential> SteamUserCredentials { get; set; }
|
||||||
|
|
||||||
|
public SteamDbContext(DbContextOptions<SteamDbContext> options) : base(options)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
// Create a unique index on TeamId and UserId
|
||||||
|
modelBuilder.Entity<SteamUserCredential>()
|
||||||
|
.HasIndex(c => new { c.TeamId, c.UserId })
|
||||||
|
.IsUnique();
|
||||||
|
}
|
||||||
|
}
|
||||||
12
packages/steam/DBSchema.cs
Normal file
12
packages/steam/DBSchema.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
public class SteamUserCredential
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public required string TeamId { get; set; }
|
||||||
|
public required string UserId { get; set; }
|
||||||
|
public required string AccountName { get; set; }
|
||||||
|
public required string RefreshToken { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// Composite key of TeamId and UserId will be unique
|
||||||
|
}
|
||||||
60
packages/steam/Migrations/20250322023207_InitialCreate.Designer.cs
generated
Normal file
60
packages/steam/Migrations/20250322023207_InitialCreate.Designer.cs
generated
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace steam.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(SteamDbContext))]
|
||||||
|
[Migration("20250322023207_InitialCreate")]
|
||||||
|
partial class InitialCreate
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "9.0.3");
|
||||||
|
|
||||||
|
modelBuilder.Entity("SteamUserCredential", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AccountName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("RefreshToken")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("TeamId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("UserId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TeamId", "UserId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("SteamUserCredentials");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
46
packages/steam/Migrations/20250322023207_InitialCreate.cs
Normal file
46
packages/steam/Migrations/20250322023207_InitialCreate.cs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace steam.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class InitialCreate : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "SteamUserCredentials",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
.Annotation("Sqlite:Autoincrement", true),
|
||||||
|
TeamId = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
UserId = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
AccountName = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
RefreshToken = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_SteamUserCredentials", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_SteamUserCredentials_TeamId_UserId",
|
||||||
|
table: "SteamUserCredentials",
|
||||||
|
columns: new[] { "TeamId", "UserId" },
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "SteamUserCredentials");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
57
packages/steam/Migrations/AppDbContextModelSnapshot.cs
Normal file
57
packages/steam/Migrations/AppDbContextModelSnapshot.cs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace steam.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(SteamDbContext))]
|
||||||
|
partial class AppDbContextModelSnapshot : ModelSnapshot
|
||||||
|
{
|
||||||
|
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "9.0.3");
|
||||||
|
|
||||||
|
modelBuilder.Entity("SteamUserCredential", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AccountName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("RefreshToken")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("TeamId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("UserId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TeamId", "UserId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("SteamUserCredentials");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
331
packages/steam/Program.cs
Normal file
331
packages/steam/Program.cs
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http.Features;
|
||||||
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
// FYI: Am very new to C# if you find any bugs or have any feedback hit me up :P
|
||||||
|
// TBH i dunno what this code does, only God and Claude know(in the slightest) what it does.
|
||||||
|
// And yes! It does not sit right with me - am learning C# as we go, i guess 🤧
|
||||||
|
// This is the server to connect to the Steam APIs and do stuff like:
|
||||||
|
// - authenticate a user,
|
||||||
|
// - get their library,
|
||||||
|
// - generate .vdf files for Steam Client (Steam manifest files), etc etc
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
// Add JWT Authentication
|
||||||
|
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||||
|
.AddJwtBearer(options =>
|
||||||
|
{
|
||||||
|
options.TokenValidationParameters = new TokenValidationParameters
|
||||||
|
{
|
||||||
|
ValidateIssuer = true,
|
||||||
|
ValidIssuer = Environment.GetEnvironmentVariable("NESTRI_AUTH_JWKS_URL"),
|
||||||
|
ValidateAudience = false,
|
||||||
|
ValidateLifetime = true,
|
||||||
|
ValidateIssuerSigningKey = true,
|
||||||
|
RequireSignedTokens = true,
|
||||||
|
RequireExpirationTime = true,
|
||||||
|
ClockSkew = TimeSpan.Zero,
|
||||||
|
|
||||||
|
// Configure the issuer signing key provider
|
||||||
|
IssuerSigningKeyResolver = (token, securityToken, kid, validationParameters) =>
|
||||||
|
{
|
||||||
|
// Fetch the JWKS manually
|
||||||
|
var jwksUrl = $"{Environment.GetEnvironmentVariable("NESTRI_AUTH_JWKS_URL")}/.well-known/jwks.json";
|
||||||
|
var httpClient = new HttpClient();
|
||||||
|
var jwksJson = httpClient.GetStringAsync(jwksUrl).Result;
|
||||||
|
var jwks = JsonSerializer.Deserialize<JsonWebKeySet>(jwksJson);
|
||||||
|
|
||||||
|
// Return all keys or filter by kid if provided
|
||||||
|
if (string.IsNullOrEmpty(kid))
|
||||||
|
return jwks?.Keys;
|
||||||
|
else
|
||||||
|
return jwks?.Keys.Where(k => k.Kid == kid);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add logging for debugging
|
||||||
|
options.Events = new JwtBearerEvents
|
||||||
|
{
|
||||||
|
OnAuthenticationFailed = context =>
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Authentication failed: {context.Exception.Message}");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
},
|
||||||
|
OnTokenValidated = context =>
|
||||||
|
{
|
||||||
|
Console.WriteLine("Token successfully validated");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services.AddAuthorization();
|
||||||
|
|
||||||
|
// Configure CORS
|
||||||
|
builder.Services.AddCors(options =>
|
||||||
|
{
|
||||||
|
options.AddDefaultPolicy(
|
||||||
|
policy =>
|
||||||
|
{
|
||||||
|
policy.AllowAnyOrigin()
|
||||||
|
.AllowAnyHeader()
|
||||||
|
.AllowAnyMethod();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services.AddSingleton<SteamService>();
|
||||||
|
|
||||||
|
builder.Services.AddDbContext<SteamDbContext>(options =>
|
||||||
|
options.UseSqlite($"Data Source=/tmp/steam.db"));
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
app.UseCors();
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
|
||||||
|
app.MapGet("/", () => "Hello World!");
|
||||||
|
|
||||||
|
app.MapGet("/status", [Authorize] async (HttpContext context, SteamService steamService) =>
|
||||||
|
{
|
||||||
|
// Validate JWT
|
||||||
|
var jwtToken = context.Request.Headers["Authorization"].ToString().Replace("Bearer ", "");
|
||||||
|
var (isValid, userId, email) = await ValidateJwtToken(jwtToken);
|
||||||
|
|
||||||
|
if (!isValid)
|
||||||
|
{
|
||||||
|
return Results.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get team ID
|
||||||
|
var teamId = context.Request.Headers["x-nestri-team"].ToString();
|
||||||
|
if (string.IsNullOrEmpty(teamId))
|
||||||
|
{
|
||||||
|
return Results.BadRequest("Missing team ID");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is authenticated with Steam
|
||||||
|
var userInfo = await steamService.GetUserInfoFromStoredCredentials(teamId, userId!);
|
||||||
|
if (userInfo == null)
|
||||||
|
{
|
||||||
|
return Results.Ok(new { isAuthenticated = false });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Ok(new
|
||||||
|
{
|
||||||
|
isAuthenticated = true,
|
||||||
|
steamId = userInfo.SteamId,
|
||||||
|
username = userInfo.Username
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapGet("/login", [Authorize] async (HttpContext context, SteamService steamService) =>
|
||||||
|
{
|
||||||
|
// Validate JWT
|
||||||
|
var jwtToken = context.Request.Headers["Authorization"].ToString().Replace("Bearer ", "");
|
||||||
|
var (isValid, userId, email) = await ValidateJwtToken(jwtToken);
|
||||||
|
|
||||||
|
Console.WriteLine($"User data: {userId}:{email}");
|
||||||
|
|
||||||
|
if (!isValid)
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = 401;
|
||||||
|
await context.Response.WriteAsync("Invalid JWT token");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get team ID
|
||||||
|
var teamId = context.Request.Headers["x-nestri-team"].ToString();
|
||||||
|
if (string.IsNullOrEmpty(teamId))
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = 400;
|
||||||
|
await context.Response.WriteAsync("Missing team ID");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set SSE headers
|
||||||
|
context.Response.Headers.Append("Connection", "keep-alive");
|
||||||
|
context.Response.Headers.Append("Cache-Control", "no-cache");
|
||||||
|
context.Response.Headers.Append("Content-Type", "text/event-stream");
|
||||||
|
context.Response.Headers.Append("Access-Control-Allow-Origin", "*");
|
||||||
|
|
||||||
|
// Disable response buffering
|
||||||
|
var responseBodyFeature = context.Features.Get<IHttpResponseBodyFeature>();
|
||||||
|
if (responseBodyFeature != null)
|
||||||
|
{
|
||||||
|
responseBodyFeature.DisableBuffering();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create unique client ID
|
||||||
|
var clientId = $"{teamId}:{userId}";
|
||||||
|
var cancellationToken = context.RequestAborted;
|
||||||
|
|
||||||
|
// Start Steam authentication
|
||||||
|
await steamService.StartAuthentication(teamId, userId!);
|
||||||
|
|
||||||
|
// Register for updates
|
||||||
|
var subscription = steamService.SubscribeToEvents(clientId, async (evt) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Serialize the event to SSE format
|
||||||
|
string eventMessage = evt.Serialize();
|
||||||
|
byte[] buffer = Encoding.UTF8.GetBytes(eventMessage);
|
||||||
|
|
||||||
|
await context.Response.Body.WriteAsync(buffer, 0, buffer.Length, cancellationToken);
|
||||||
|
await context.Response.Body.FlushAsync(cancellationToken);
|
||||||
|
|
||||||
|
Console.WriteLine($"Sent event type '{evt.Type}' to client {clientId}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Error sending event to client {clientId}: {ex.Message}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep the connection alive until canceled
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(Timeout.Infinite, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Client {clientId} disconnected");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
steamService.Unsubscribe(clientId, subscription);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapGet("/user", [Authorize] async (HttpContext context, SteamService steamService) =>
|
||||||
|
{
|
||||||
|
// Validate JWT
|
||||||
|
var jwtToken = context.Request.Headers["Authorization"].ToString().Replace("Bearer ", "");
|
||||||
|
var (isValid, userId, email) = await ValidateJwtToken(jwtToken);
|
||||||
|
|
||||||
|
if (!isValid)
|
||||||
|
{
|
||||||
|
return Results.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get team ID
|
||||||
|
var teamId = context.Request.Headers["x-nestri-team"].ToString();
|
||||||
|
if (string.IsNullOrEmpty(teamId))
|
||||||
|
{
|
||||||
|
return Results.BadRequest("Missing team ID");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user info from stored credentials
|
||||||
|
var userInfo = await steamService.GetUserInfoFromStoredCredentials(teamId, userId);
|
||||||
|
if (userInfo == null)
|
||||||
|
{
|
||||||
|
return Results.NotFound(new { error = "User not authenticated with Steam" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Ok(new
|
||||||
|
{
|
||||||
|
steamId = userInfo.SteamId,
|
||||||
|
username = userInfo.Username
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapPost("/logout", [Authorize] async (HttpContext context, SteamService steamService) =>
|
||||||
|
{
|
||||||
|
// Validate JWT
|
||||||
|
var jwtToken = context.Request.Headers["Authorization"].ToString().Replace("Bearer ", "");
|
||||||
|
var (isValid, userId, email) = await ValidateJwtToken(jwtToken);
|
||||||
|
|
||||||
|
if (!isValid)
|
||||||
|
{
|
||||||
|
return Results.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get team ID
|
||||||
|
var teamId = context.Request.Headers["x-nestri-team"].ToString();
|
||||||
|
if (string.IsNullOrEmpty(teamId))
|
||||||
|
{
|
||||||
|
return Results.BadRequest("Missing team ID");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the stored credentials
|
||||||
|
using var scope = context.RequestServices.CreateScope();
|
||||||
|
var dbContext = scope.ServiceProvider.GetRequiredService<SteamDbContext>();
|
||||||
|
|
||||||
|
var credentials = await dbContext.SteamUserCredentials
|
||||||
|
.FirstOrDefaultAsync(c => c.TeamId == teamId && c.UserId == userId);
|
||||||
|
|
||||||
|
if (credentials != null)
|
||||||
|
{
|
||||||
|
dbContext.SteamUserCredentials.Remove(credentials);
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
return Results.Ok(new { message = "Steam authentication revoked" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.NotFound(new { error = "No Steam authentication found" });
|
||||||
|
});
|
||||||
|
|
||||||
|
// JWT validation function
|
||||||
|
async Task<(bool IsValid, string? UserId, string? Email)> ValidateJwtToken(string token)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var jwksUrl = Environment.GetEnvironmentVariable("NESTRI_AUTH_JWKS_URL");
|
||||||
|
var handler = new JwtSecurityTokenHandler();
|
||||||
|
var jwtToken = handler.ReadJwtToken(token);
|
||||||
|
|
||||||
|
// Log all claims for debugging
|
||||||
|
// Console.WriteLine("JWT Claims:");
|
||||||
|
// foreach (var claim in jwtToken.Claims)
|
||||||
|
// {
|
||||||
|
// Console.WriteLine($" {claim.Type}: {claim.Value}");
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Validate token using JWKS
|
||||||
|
var httpClient = new HttpClient();
|
||||||
|
var jwksJson = await httpClient.GetStringAsync($"{jwksUrl}/.well-known/jwks.json");
|
||||||
|
var jwks = JsonSerializer.Deserialize<JsonWebKeySet>(jwksJson);
|
||||||
|
|
||||||
|
// Extract the properties claim which contains nested JSON
|
||||||
|
var propertiesClaim = jwtToken.Claims.FirstOrDefault(c => c.Type == "properties")?.Value;
|
||||||
|
if (!string.IsNullOrEmpty(propertiesClaim))
|
||||||
|
{
|
||||||
|
// Parse the nested JSON
|
||||||
|
var properties = JsonSerializer.Deserialize<Dictionary<string, string>>(propertiesClaim);
|
||||||
|
|
||||||
|
// Extract userID from properties
|
||||||
|
var email = properties?.GetValueOrDefault("email");
|
||||||
|
var userId = properties?.GetValueOrDefault("userID");
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(email))
|
||||||
|
{
|
||||||
|
// Also check standard claims as fallback
|
||||||
|
userId = jwtToken.Claims.FirstOrDefault(c => c.Type == "sub")?.Value;
|
||||||
|
email = jwtToken.Claims.FirstOrDefault(c => c.Type == "email")?.Value;
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(email))
|
||||||
|
{
|
||||||
|
return (false, null, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (true, userId, email);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (false, null, null);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"JWT validation error: {ex.Message}");
|
||||||
|
return (false, null, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine("Server started. Press Ctrl+C to stop.");
|
||||||
|
await app.RunAsync();
|
||||||
38
packages/steam/Properties/launchSettings.json
Normal file
38
packages/steam/Properties/launchSettings.json
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||||
|
"iisSettings": {
|
||||||
|
"windowsAuthentication": false,
|
||||||
|
"anonymousAuthentication": true,
|
||||||
|
"iisExpress": {
|
||||||
|
"applicationUrl": "http://localhost:12427",
|
||||||
|
"sslPort": 44354
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"http": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": true,
|
||||||
|
"applicationUrl": "http://localhost:5289",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"https": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": true,
|
||||||
|
"applicationUrl": "https://localhost:7168;http://localhost:5289",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"IIS Express": {
|
||||||
|
"commandName": "IISExpress",
|
||||||
|
"launchBrowser": true,
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
packages/steam/SSEEvents.cs
Normal file
19
packages/steam/SSEEvents.cs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
public class ServerSentEvent
|
||||||
|
{
|
||||||
|
public string Type { get; set; }
|
||||||
|
public object Data { get; set; }
|
||||||
|
|
||||||
|
public ServerSentEvent(string type, object data)
|
||||||
|
{
|
||||||
|
Type = type;
|
||||||
|
Data = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Serialize()
|
||||||
|
{
|
||||||
|
var dataJson = JsonSerializer.Serialize(Data);
|
||||||
|
return $"event: {Type}\ndata: {dataJson}\n\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
357
packages/steam/SteamClient.cs
Normal file
357
packages/steam/SteamClient.cs
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
using SteamKit2;
|
||||||
|
using SteamKit2.Authentication;
|
||||||
|
|
||||||
|
// Steam client handler
|
||||||
|
public class SteamClientHandler
|
||||||
|
{
|
||||||
|
private readonly string _clientId;
|
||||||
|
private readonly SteamClient _steamClient;
|
||||||
|
private readonly CallbackManager _manager;
|
||||||
|
private readonly SteamUser _steamUser;
|
||||||
|
public event Action<ServerSentEvent>? OnEvent;
|
||||||
|
private readonly List<Action<string>> _subscribers = new();
|
||||||
|
private QrAuthSession? _authSession;
|
||||||
|
private Task? _callbackTask;
|
||||||
|
private CancellationTokenSource? _cts;
|
||||||
|
private bool _isAuthenticated = false;
|
||||||
|
|
||||||
|
public SteamUserInfo? UserInfo { get; private set; }
|
||||||
|
|
||||||
|
// Add a callback for when credentials are obtained
|
||||||
|
private readonly Action<string, string>? _onCredentialsObtained;
|
||||||
|
|
||||||
|
// Update constructor to optionally receive the callback
|
||||||
|
public SteamClientHandler(string clientId, Action<string, string>? onCredentialsObtained = null)
|
||||||
|
{
|
||||||
|
_clientId = clientId;
|
||||||
|
_onCredentialsObtained = onCredentialsObtained;
|
||||||
|
_steamClient = new SteamClient(SteamConfiguration.Create(e => e.WithConnectionTimeout(TimeSpan.FromSeconds(120))));
|
||||||
|
_manager = new CallbackManager(_steamClient);
|
||||||
|
_steamUser = _steamClient.GetHandler<SteamUser>()!;
|
||||||
|
|
||||||
|
// Register callbacks
|
||||||
|
_manager.Subscribe<SteamClient.ConnectedCallback>(OnConnected);
|
||||||
|
_manager.Subscribe<SteamClient.DisconnectedCallback>(OnDisconnected);
|
||||||
|
_manager.Subscribe<SteamUser.LoggedOnCallback>(OnLoggedOn);
|
||||||
|
_manager.Subscribe<SteamUser.LoggedOffCallback>(OnLoggedOff);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add method to login with stored credentials
|
||||||
|
public async Task<bool> LoginWithStoredCredentialsAsync(string accountName, string refreshToken)
|
||||||
|
{
|
||||||
|
if (_callbackTask != null)
|
||||||
|
{
|
||||||
|
return _isAuthenticated; // Already connected
|
||||||
|
}
|
||||||
|
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
|
|
||||||
|
// Connect to Steam
|
||||||
|
Console.WriteLine($"[{_clientId}] Connecting to Steam with stored credentials...");
|
||||||
|
_steamClient.Connect();
|
||||||
|
|
||||||
|
// Start callback loop
|
||||||
|
_callbackTask = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
while (!_cts.Token.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
_manager.RunWaitCallbacks(TimeSpan.FromSeconds(1));
|
||||||
|
await Task.Delay(10);
|
||||||
|
}
|
||||||
|
}, _cts.Token);
|
||||||
|
|
||||||
|
// Wait for connection
|
||||||
|
var connectionTask = new TaskCompletionSource<bool>();
|
||||||
|
var connectedHandler = _manager.Subscribe<SteamClient.ConnectedCallback>(callback =>
|
||||||
|
{
|
||||||
|
// Once connected, try to log in with stored credentials
|
||||||
|
Console.WriteLine($"[{_clientId}] Connected to Steam, logging in with stored credentials");
|
||||||
|
_steamUser.LogOn(new SteamUser.LogOnDetails
|
||||||
|
{
|
||||||
|
Username = accountName,
|
||||||
|
AccessToken = refreshToken
|
||||||
|
});
|
||||||
|
connectionTask.TrySetResult(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up a handler for the login result
|
||||||
|
var loginResultTask = new TaskCompletionSource<bool>();
|
||||||
|
var loggedOnHandler = _manager.Subscribe<SteamUser.LoggedOnCallback>(callback =>
|
||||||
|
{
|
||||||
|
if (callback.Result == EResult.OK)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[{_clientId}] Successfully logged on with stored credentials");
|
||||||
|
_isAuthenticated = true;
|
||||||
|
UserInfo = new SteamUserInfo
|
||||||
|
{
|
||||||
|
SteamId = callback.ClientSteamID.ToString(),
|
||||||
|
Username = accountName
|
||||||
|
};
|
||||||
|
loginResultTask.TrySetResult(true);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[{_clientId}] Failed to log on with stored credentials: {callback.Result}");
|
||||||
|
loginResultTask.TrySetResult(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add a timeout
|
||||||
|
var timeoutTask = Task.Delay(TimeSpan.FromSeconds(30));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await connectionTask.Task;
|
||||||
|
|
||||||
|
var completedTask = await Task.WhenAny(loginResultTask.Task, timeoutTask);
|
||||||
|
|
||||||
|
if (completedTask == timeoutTask)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[{_clientId}] Login with stored credentials timed out");
|
||||||
|
Shutdown();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await loginResultTask.Task;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[{_clientId}] Error logging in with stored credentials: {ex.Message}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// finally
|
||||||
|
// {
|
||||||
|
// _manager.Unsubscribe(connectedHandler);
|
||||||
|
// _manager.Unsubscribe(loggedOnHandler);
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task StartAuthenticationAsync()
|
||||||
|
{
|
||||||
|
if (_callbackTask != null)
|
||||||
|
{
|
||||||
|
// Authentication already in progress
|
||||||
|
if (_authSession != null)
|
||||||
|
{
|
||||||
|
// Just resend the current QR code URL to all subscribers
|
||||||
|
NotifySubscribers(_authSession.ChallengeURL);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
|
|
||||||
|
// Connect to Steam
|
||||||
|
Console.WriteLine($"[{_clientId}] Connecting to Steam...");
|
||||||
|
_steamClient.Connect();
|
||||||
|
|
||||||
|
// Start callback loop
|
||||||
|
_callbackTask = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
while (!_cts.Token.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
_manager.RunWaitCallbacks(TimeSpan.FromSeconds(1));
|
||||||
|
await Task.Delay(10);
|
||||||
|
}
|
||||||
|
}, _cts.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void NotifyEvent(ServerSentEvent evt)
|
||||||
|
{
|
||||||
|
OnEvent?.Invoke(evt);
|
||||||
|
|
||||||
|
// Also notify the legacy subscribers with just the URL if this is a URL event
|
||||||
|
if (evt.Type == "url" && evt.Data is string url)
|
||||||
|
{
|
||||||
|
NotifySubscribers(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnConnected(SteamClient.ConnectedCallback callback)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[{_clientId}] Connected to Steam");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Start QR authentication session
|
||||||
|
_authSession = await _steamClient.Authentication.BeginAuthSessionViaQRAsync(new AuthSessionDetails());
|
||||||
|
|
||||||
|
// Handle QR code URL changes
|
||||||
|
_authSession.ChallengeURLChanged = () =>
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[{_clientId}] QR challenge URL refreshed");
|
||||||
|
NotifyEvent(new ServerSentEvent("url", _authSession.ChallengeURL));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send initial QR code URL
|
||||||
|
NotifyEvent(new ServerSentEvent("url", _authSession.ChallengeURL));
|
||||||
|
|
||||||
|
// Start polling for authentication result
|
||||||
|
await Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pollResponse = await _authSession.PollingWaitForResultAsync();
|
||||||
|
|
||||||
|
Console.WriteLine($"[{_clientId}] Logging in as '{pollResponse.AccountName}'");
|
||||||
|
|
||||||
|
// Send login attempt event
|
||||||
|
NotifyEvent(new ServerSentEvent("login-attempt", new { username = pollResponse.AccountName }));
|
||||||
|
|
||||||
|
// Login to Steam
|
||||||
|
_steamUser.LogOn(new SteamUser.LogOnDetails
|
||||||
|
{
|
||||||
|
Username = pollResponse.AccountName,
|
||||||
|
AccessToken = pollResponse.RefreshToken,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[{_clientId}] Authentication polling error: {ex.Message}");
|
||||||
|
NotifyEvent(new ServerSentEvent("login-unsuccessful", new { error = ex.Message }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[{_clientId}] Error starting authentication: {ex.Message}");
|
||||||
|
NotifyEvent(new ServerSentEvent("login-unsuccessful", new { error = ex.Message }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDisconnected(SteamClient.DisconnectedCallback callback)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[{_clientId}] Disconnected from Steam");
|
||||||
|
|
||||||
|
_isAuthenticated = false;
|
||||||
|
UserInfo = null;
|
||||||
|
|
||||||
|
// Reconnect if not intentionally stopped
|
||||||
|
if (_callbackTask != null && !_cts.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[{_clientId}] Reconnecting...");
|
||||||
|
_steamClient.Connect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnLoggedOn(SteamUser.LoggedOnCallback callback)
|
||||||
|
{
|
||||||
|
if (callback.Result != EResult.OK)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[{_clientId}] Unable to log on to Steam: {callback.Result} / {callback.ExtendedResult}");
|
||||||
|
NotifyEvent(new ServerSentEvent("login-unsuccessful", new
|
||||||
|
{
|
||||||
|
error = $"Steam login failed: {callback.Result}",
|
||||||
|
extendedError = callback.ExtendedResult.ToString()
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine($"[{_clientId}] Successfully logged on as {callback.ClientSteamID}");
|
||||||
|
|
||||||
|
_isAuthenticated = true;
|
||||||
|
|
||||||
|
// Get the username from the authentication session
|
||||||
|
string accountName = _authSession?.PollingWaitForResultAsync().Result.AccountName ?? "Unknown";
|
||||||
|
string refreshToken = _authSession?.PollingWaitForResultAsync().Result.RefreshToken ?? "";
|
||||||
|
|
||||||
|
UserInfo = new SteamUserInfo
|
||||||
|
{
|
||||||
|
SteamId = callback.ClientSteamID.ToString(),
|
||||||
|
Username = accountName
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send login success event
|
||||||
|
NotifyEvent(new ServerSentEvent("login-success", new
|
||||||
|
{
|
||||||
|
steamId = callback.ClientSteamID.ToString(),
|
||||||
|
username = accountName
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Save credentials if callback is provided
|
||||||
|
if (_onCredentialsObtained != null && !string.IsNullOrEmpty(refreshToken))
|
||||||
|
{
|
||||||
|
_onCredentialsObtained(accountName, refreshToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnLoggedOff(SteamUser.LoggedOffCallback callback)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[{_clientId}] Logged off of Steam: {callback.Result}");
|
||||||
|
|
||||||
|
_isAuthenticated = false;
|
||||||
|
UserInfo = null;
|
||||||
|
|
||||||
|
//Unnecessary but just in case the frontend wants to listen to this
|
||||||
|
NotifyEvent(new ServerSentEvent("logged-off", new
|
||||||
|
{
|
||||||
|
reason = callback.Result.ToString()
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Action Subscribe(Action<ServerSentEvent> callback)
|
||||||
|
{
|
||||||
|
OnEvent += callback;
|
||||||
|
|
||||||
|
// If we already have a QR code URL, send it immediately
|
||||||
|
if (_authSession != null)
|
||||||
|
{
|
||||||
|
callback(new ServerSentEvent("url", _authSession.ChallengeURL));
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => OnEvent -= callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the old Subscribe method for backward compatibility
|
||||||
|
public Action Subscribe(Action<string> callback)
|
||||||
|
{
|
||||||
|
lock (_subscribers)
|
||||||
|
{
|
||||||
|
_subscribers.Add(callback);
|
||||||
|
|
||||||
|
// If we already have a QR code URL, send it immediately
|
||||||
|
if (_authSession != null)
|
||||||
|
{
|
||||||
|
callback(_authSession.ChallengeURL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () =>
|
||||||
|
{
|
||||||
|
lock (_subscribers)
|
||||||
|
{
|
||||||
|
_subscribers.Remove(callback);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void NotifySubscribers(string url)
|
||||||
|
{
|
||||||
|
lock (_subscribers)
|
||||||
|
{
|
||||||
|
foreach (var subscriber in _subscribers)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
subscriber(url);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[{_clientId}] Error notifying subscriber: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Shutdown()
|
||||||
|
{
|
||||||
|
_cts?.Cancel();
|
||||||
|
_steamClient.Disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SteamUserInfo
|
||||||
|
{
|
||||||
|
public string SteamId { get; set; } = string.Empty;
|
||||||
|
public string Username { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
156
packages/steam/SteamService.cs
Normal file
156
packages/steam/SteamService.cs
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
using SteamKit2;
|
||||||
|
using SteamKit2.Authentication;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
|
||||||
|
// Steam Service
|
||||||
|
public class SteamService
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<string, SteamClientHandler> _clientHandlers = new();
|
||||||
|
|
||||||
|
private readonly IServiceProvider _serviceProvider;
|
||||||
|
|
||||||
|
public SteamService(IServiceProvider serviceProvider)
|
||||||
|
{
|
||||||
|
_serviceProvider = serviceProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Action SubscribeToEvents(string clientId, Action<ServerSentEvent> callback)
|
||||||
|
{
|
||||||
|
if (_clientHandlers.TryGetValue(clientId, out var handler))
|
||||||
|
{
|
||||||
|
return handler.Subscribe(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => { }; // Empty unsubscribe function
|
||||||
|
}
|
||||||
|
public async Task StartAuthentication(string teamId, string userId)
|
||||||
|
{
|
||||||
|
var clientId = $"{teamId}:{userId}";
|
||||||
|
|
||||||
|
// Check if we already have stored credentials
|
||||||
|
using var scope = _serviceProvider.CreateScope();
|
||||||
|
var dbContext = scope.ServiceProvider.GetRequiredService<SteamDbContext>();
|
||||||
|
var storedCredential = await dbContext.SteamUserCredentials
|
||||||
|
.FirstOrDefaultAsync(c => c.TeamId == teamId && c.UserId == userId);
|
||||||
|
|
||||||
|
var handler = _clientHandlers.GetOrAdd(clientId, id => new SteamClientHandler(id,
|
||||||
|
async (accountName, refreshToken) => await SaveCredentials(teamId, userId, accountName, refreshToken)));
|
||||||
|
|
||||||
|
if (storedCredential != null)
|
||||||
|
{
|
||||||
|
// We have stored credentials, try to use them
|
||||||
|
var success = await handler.LoginWithStoredCredentialsAsync(storedCredential.AccountName, storedCredential.RefreshToken);
|
||||||
|
|
||||||
|
// If login failed, start fresh authentication
|
||||||
|
if (!success)
|
||||||
|
{
|
||||||
|
await handler.StartAuthenticationAsync();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No stored credentials, start fresh authentication
|
||||||
|
await handler.StartAuthenticationAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveCredentials(string teamId, string userId, string accountName, string refreshToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = _serviceProvider.CreateScope();
|
||||||
|
var dbContext = scope.ServiceProvider.GetRequiredService<SteamDbContext>();
|
||||||
|
|
||||||
|
var existingCredential = await dbContext.SteamUserCredentials
|
||||||
|
.FirstOrDefaultAsync(c => c.TeamId == teamId && c.UserId == userId);
|
||||||
|
|
||||||
|
if (existingCredential != null)
|
||||||
|
{
|
||||||
|
// Update existing record
|
||||||
|
existingCredential.AccountName = accountName;
|
||||||
|
existingCredential.RefreshToken = refreshToken;
|
||||||
|
existingCredential.UpdatedAt = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Create new record
|
||||||
|
dbContext.SteamUserCredentials.Add(new SteamUserCredential
|
||||||
|
{
|
||||||
|
TeamId = teamId,
|
||||||
|
UserId = userId,
|
||||||
|
AccountName = accountName,
|
||||||
|
RefreshToken = refreshToken
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
Console.WriteLine($"Saved Steam credentials for {teamId}:{userId}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Error saving credentials: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SteamUserInfo?> GetUserInfoFromStoredCredentials(string teamId, string userId)
|
||||||
|
{
|
||||||
|
var clientId = $"{teamId}:{userId}";
|
||||||
|
|
||||||
|
// Check if we have an active session
|
||||||
|
if (_clientHandlers.TryGetValue(clientId, out var activeHandler) && activeHandler.UserInfo != null)
|
||||||
|
{
|
||||||
|
return activeHandler.UserInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get stored credentials
|
||||||
|
using var scope = _serviceProvider.CreateScope();
|
||||||
|
var dbContext = scope.ServiceProvider.GetRequiredService<SteamDbContext>();
|
||||||
|
var storedCredential = await dbContext.SteamUserCredentials
|
||||||
|
.FirstOrDefaultAsync(c => c.TeamId == teamId && c.UserId == userId);
|
||||||
|
|
||||||
|
if (storedCredential == null)
|
||||||
|
{
|
||||||
|
return null; // No stored credentials
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new handler and try to log in
|
||||||
|
var handler = new SteamClientHandler(clientId);
|
||||||
|
var success = await handler.LoginWithStoredCredentialsAsync(
|
||||||
|
storedCredential.AccountName,
|
||||||
|
storedCredential.RefreshToken);
|
||||||
|
|
||||||
|
if (success)
|
||||||
|
{
|
||||||
|
_clientHandlers.TryAdd(clientId, handler);
|
||||||
|
return handler.UserInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login failed, credentials might be invalid
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Action Subscribe(string clientId, Action<string> callback)
|
||||||
|
{
|
||||||
|
if (_clientHandlers.TryGetValue(clientId, out var handler))
|
||||||
|
{
|
||||||
|
return handler.Subscribe(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => { }; // Empty unsubscribe function
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Unsubscribe(string clientId, Action unsubscribeAction)
|
||||||
|
{
|
||||||
|
unsubscribeAction();
|
||||||
|
}
|
||||||
|
|
||||||
|
public SteamUserInfo? GetUserInfo(string clientId)
|
||||||
|
{
|
||||||
|
if (_clientHandlers.TryGetValue(clientId, out var handler))
|
||||||
|
{
|
||||||
|
return handler.UserInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
packages/steam/appsettings.Development.json
Normal file
8
packages/steam/appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
packages/steam/appsettings.json
Normal file
9
packages/steam/appsettings.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*"
|
||||||
|
}
|
||||||
19
packages/steam/steam.csproj
Normal file
19
packages/steam/steam.csproj
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.14" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.3">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.3" />
|
||||||
|
<PackageReference Include="SteamKit2" Version="3.0.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -9,12 +9,12 @@
|
|||||||
<meta
|
<meta
|
||||||
name="theme-color"
|
name="theme-color"
|
||||||
media="(prefers-color-scheme: light)"
|
media="(prefers-color-scheme: light)"
|
||||||
content="#f5f5f5"
|
content="rgba(255,255,255,0.8)"
|
||||||
/>
|
/>
|
||||||
<meta
|
<meta
|
||||||
name="theme-color"
|
name="theme-color"
|
||||||
media="(prefers-color-scheme: dark)"
|
media="(prefers-color-scheme: dark)"
|
||||||
content="#171717"
|
content="rgb(19,21,23)"
|
||||||
/>
|
/>
|
||||||
<link
|
<link
|
||||||
rel="apple-touch-icon"
|
rel="apple-touch-icon"
|
||||||
|
|||||||
@@ -9,8 +9,9 @@
|
|||||||
"typecheck": "tsc --noEmit --incremental"
|
"typecheck": "tsc --noEmit --incremental"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@macaron-css/vite": "^1.5.1",
|
"@macaron-css/vite": "1.5.1",
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
|
"@types/qrcode": "^1.5.5",
|
||||||
"vite": "5.4.12",
|
"vite": "5.4.12",
|
||||||
"vite-plugin-solid": "^2.11.2"
|
"vite-plugin-solid": "^2.11.2"
|
||||||
},
|
},
|
||||||
@@ -21,14 +22,24 @@
|
|||||||
"@fontsource-variable/geist-mono": "^5.0.1",
|
"@fontsource-variable/geist-mono": "^5.0.1",
|
||||||
"@fontsource-variable/mona-sans": "^5.0.1",
|
"@fontsource-variable/mona-sans": "^5.0.1",
|
||||||
"@fontsource/geist-sans": "^5.1.0",
|
"@fontsource/geist-sans": "^5.1.0",
|
||||||
"@macaron-css/core": "^1.5.2",
|
"@macaron-css/core": "1.5.1",
|
||||||
"@macaron-css/solid": "^1.5.3",
|
"@macaron-css/solid": "1.5.3",
|
||||||
"@modular-forms/solid": "^0.25.1",
|
"@modular-forms/solid": "^0.25.1",
|
||||||
"@nestri/core": "*",
|
"@nestri/core": "*",
|
||||||
"@openauthjs/openauth": "0.4.3",
|
"@nestri/functions": "*",
|
||||||
|
"@nestri/zero": "*",
|
||||||
|
"@openauthjs/openauth": "*",
|
||||||
|
"@openauthjs/solid": "0.0.0-20250311201457",
|
||||||
|
"@rocicorp/zero": "*",
|
||||||
|
"@solid-primitives/event-listener": "^2.4.0",
|
||||||
"@solid-primitives/storage": "^4.3.1",
|
"@solid-primitives/storage": "^4.3.1",
|
||||||
"@solidjs/router": "^0.15.3",
|
"@solidjs/router": "^0.15.3",
|
||||||
|
"body-scroll-lock-upgrade": "^1.1.0",
|
||||||
|
"eventsource": "^3.0.5",
|
||||||
|
"focus-trap": "^7.6.4",
|
||||||
|
"hono": "^4.7.4",
|
||||||
"modern-normalize": "^3.0.1",
|
"modern-normalize": "^3.0.1",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"solid-js": "^1.9.5",
|
"solid-js": "^1.9.5",
|
||||||
"valibot": "^1.0.0-rc.3",
|
"valibot": "^1.0.0-rc.3",
|
||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
|
|||||||
@@ -6,13 +6,18 @@ import '@fontsource/geist-sans/600.css';
|
|||||||
import '@fontsource/geist-sans/700.css';
|
import '@fontsource/geist-sans/700.css';
|
||||||
import '@fontsource/geist-sans/800.css';
|
import '@fontsource/geist-sans/800.css';
|
||||||
import '@fontsource/geist-sans/900.css';
|
import '@fontsource/geist-sans/900.css';
|
||||||
|
import { Text } from '@nestri/www/ui/text';
|
||||||
import { styled } from "@macaron-css/solid";
|
import { styled } from "@macaron-css/solid";
|
||||||
import { useStorage } from './providers/account';
|
import { Screen as FullScreen } from '@nestri/www/ui/layout';
|
||||||
import { CreateTeamComponent } from './pages/new';
|
import { TeamRoute } from '@nestri/www/pages/team';
|
||||||
import { darkClass, lightClass, theme } from './ui/theme';
|
import { OpenAuthProvider } from "@openauthjs/solid";
|
||||||
import { AuthProvider, useAuth } from './providers/auth';
|
import { NotFound } from '@nestri/www/pages/not-found';
|
||||||
import { Navigate, Route, Router } from "@solidjs/router";
|
import { Navigate, Route, Router } from "@solidjs/router";
|
||||||
import { globalStyle, macaron$ } from "@macaron-css/core";
|
import { globalStyle, macaron$ } from "@macaron-css/core";
|
||||||
|
import { useStorage } from '@nestri/www/providers/account';
|
||||||
|
import { CreateTeamComponent } from '@nestri/www/pages/new';
|
||||||
|
import { darkClass, lightClass, theme } from '@nestri/www/ui/theme';
|
||||||
|
import { AccountProvider, useAccount } from '@nestri/www/providers/account';
|
||||||
import { Component, createSignal, Match, onCleanup, Switch } from 'solid-js';
|
import { Component, createSignal, Match, onCleanup, Switch } from 'solid-js';
|
||||||
|
|
||||||
const Root = styled("div", {
|
const Root = styled("div", {
|
||||||
@@ -34,14 +39,19 @@ globalStyle("html", {
|
|||||||
// Hardcode colors
|
// Hardcode colors
|
||||||
"@media": {
|
"@media": {
|
||||||
"(prefers-color-scheme: light)": {
|
"(prefers-color-scheme: light)": {
|
||||||
backgroundColor: "#f5f5f5",
|
backgroundColor: "rgba(255,255,255,0.8)",
|
||||||
},
|
},
|
||||||
"(prefers-color-scheme: dark)": {
|
"(prefers-color-scheme: dark)": {
|
||||||
backgroundColor: "#1e1e1e",
|
backgroundColor: "rgb(19,21,23)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
globalStyle("dialog:modal", {
|
||||||
|
maxHeight: "unset",
|
||||||
|
maxWidth: "unset"
|
||||||
|
})
|
||||||
|
|
||||||
globalStyle("h1, h2, h3, h4, h5, h6, p", {
|
globalStyle("h1, h2, h3, h4, h5, h6, p", {
|
||||||
margin: 0,
|
margin: 0,
|
||||||
});
|
});
|
||||||
@@ -82,44 +92,54 @@ export const App: Component = () => {
|
|||||||
const storage = useStorage();
|
const storage = useStorage();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Root class={theme() === "light" ? lightClass : darkClass} id="styled">
|
<OpenAuthProvider
|
||||||
<Router>
|
issuer={import.meta.env.VITE_AUTH_URL}
|
||||||
<Route
|
clientID="web"
|
||||||
path="*"
|
>
|
||||||
component={(props) => (
|
<Root class={theme() === "light" ? lightClass : darkClass} id="styled">
|
||||||
<AuthProvider>
|
<Router>
|
||||||
{props.children}
|
|
||||||
</AuthProvider>
|
|
||||||
// props.children
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Route path="new" component={CreateTeamComponent} />
|
|
||||||
<Route
|
<Route
|
||||||
path="/"
|
path="*"
|
||||||
component={() => {
|
component={(props) => (
|
||||||
const auth = useAuth();
|
<AccountProvider
|
||||||
return (
|
loadingUI={
|
||||||
<Switch>
|
<FullScreen>
|
||||||
<Match when={auth.current.teams.length > 0}>
|
<Text weight='semibold' spacing='xs' size="3xl" font="heading" >Confirming your identity…</Text>
|
||||||
<Navigate
|
</FullScreen>
|
||||||
href={`/${(
|
}>
|
||||||
auth.current.teams.find(
|
{props.children}
|
||||||
(w) => w.id === storage.value.team,
|
</AccountProvider>
|
||||||
) || auth.current.teams[0]
|
)}
|
||||||
).slug
|
>
|
||||||
}`}
|
<Route path=":teamSlug">{TeamRoute}</Route>
|
||||||
/>
|
<Route path="new" component={CreateTeamComponent} />
|
||||||
</Match>
|
<Route
|
||||||
<Match when={true}>
|
path="/"
|
||||||
<Navigate href={`/new`} />
|
component={() => {
|
||||||
</Match>
|
const account = useAccount();
|
||||||
</Switch>
|
return (
|
||||||
);
|
<Switch>
|
||||||
}}
|
<Match when={account.current.teams.length > 0}>
|
||||||
/>
|
<Navigate
|
||||||
{/* <Route path="*" component={() => <NotFound />} /> */}
|
href={`/${(
|
||||||
</Route>
|
account.current.teams.find(
|
||||||
</Router>
|
(w) => w.id === storage.value.team,
|
||||||
</Root>
|
) || account.current.teams[0]
|
||||||
|
).slug
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</Match>
|
||||||
|
<Match when={true}>
|
||||||
|
<Navigate href={`/new`} />
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Route path="*" component={() => <NotFound />} />
|
||||||
|
</Route>
|
||||||
|
</Router>
|
||||||
|
</Root>
|
||||||
|
</OpenAuthProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ParentProps, Show, createContext, useContext } from "solid-js";
|
import { JSX, ParentProps, Show, createContext, useContext } from "solid-js";
|
||||||
|
|
||||||
export function createInitializedContext<
|
export function createInitializedContext<
|
||||||
Name extends string,
|
Name extends string,
|
||||||
@@ -12,10 +12,12 @@ export function createInitializedContext<
|
|||||||
if (!context) throw new Error(`No ${name} context`);
|
if (!context) throw new Error(`No ${name} context`);
|
||||||
return context;
|
return context;
|
||||||
},
|
},
|
||||||
provider: (props: ParentProps) => {
|
provider: (props: ParentProps & { loadingUI?: JSX.Element }) => {
|
||||||
const value = cb();
|
const value = cb();
|
||||||
return (
|
return (
|
||||||
<Show when={value.ready}>
|
<Show
|
||||||
|
fallback={props.loadingUI}
|
||||||
|
when={value.ready}>
|
||||||
<ctx.Provider value={value} {...props}>
|
<ctx.Provider value={value} {...props}>
|
||||||
{props.children}
|
{props.children}
|
||||||
</ctx.Provider>
|
</ctx.Provider>
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
import * as v from "valibot"
|
import * as v from "valibot"
|
||||||
import { styled } from "@macaron-css/solid";
|
import { Show } from "solid-js";
|
||||||
import { Text } from "@nestri/www/ui/text";
|
|
||||||
import { utility } from "@nestri/www/ui/utility";
|
|
||||||
import { theme } from "@nestri/www/ui/theme";
|
|
||||||
import { FormField, Input, Select } from "@nestri/www/ui/form";
|
|
||||||
import { Container, FullScreen } from "@nestri/www/ui/layout";
|
|
||||||
import { createForm, required, email, valiForm } from "@modular-forms/solid";
|
|
||||||
import { Button } from "@nestri/www/ui";
|
import { Button } from "@nestri/www/ui";
|
||||||
|
import { Text } from "@nestri/www/ui/text";
|
||||||
|
import { styled } from "@macaron-css/solid";
|
||||||
|
import { theme } from "@nestri/www/ui/theme";
|
||||||
|
import { useNavigate } from "@solidjs/router";
|
||||||
|
import { useOpenAuth } from "@openauthjs/solid";
|
||||||
|
import { utility } from "@nestri/www/ui/utility";
|
||||||
|
import { useAccount } from "../providers/account";
|
||||||
|
import { Container, FullScreen } from "@nestri/www/ui/layout";
|
||||||
|
import { FormField, Input, Select } from "@nestri/www/ui/form";
|
||||||
|
import { createForm, getValue, setError, valiForm } from "@modular-forms/solid";
|
||||||
|
|
||||||
// const nameRegex = /^[a-z]+$/
|
const nameRegex = /^[a-z0-9\-]+$/
|
||||||
|
|
||||||
const FieldList = styled("div", {
|
const FieldList = styled("div", {
|
||||||
base: {
|
base: {
|
||||||
@@ -33,19 +37,19 @@ const Plan = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const schema = v.object({
|
const schema = v.object({
|
||||||
plan: v.pipe(
|
planType: v.pipe(
|
||||||
v.enum(Plan),
|
v.enum(Plan, "Choose a valid plan"),
|
||||||
v.minLength(2,"Please choose a plan"),
|
|
||||||
),
|
),
|
||||||
display_name: v.pipe(
|
name: v.pipe(
|
||||||
v.string(),
|
v.string(),
|
||||||
v.maxLength(32, 'Please use 32 characters at maximum.'),
|
v.minLength(2, 'Use 2 characters at minimum.'),
|
||||||
|
v.maxLength(32, 'Use 32 characters at maximum.'),
|
||||||
),
|
),
|
||||||
slug: v.pipe(
|
slug: v.pipe(
|
||||||
v.string(),
|
v.string(),
|
||||||
v.minLength(2, 'Please use 2 characters at minimum.'),
|
v.regex(nameRegex, "Use a URL friendly name."),
|
||||||
// v.regex(nameRegex, "Use only small letters, no numbers or special characters"),
|
v.minLength(2, 'Use 2 characters at minimum.'),
|
||||||
v.maxLength(48, 'Please use 48 characters at maximum.'),
|
v.maxLength(48, 'Use 48 characters at maximum.'),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -82,11 +86,39 @@ const schema = v.object({
|
|||||||
// }
|
// }
|
||||||
// })
|
// })
|
||||||
|
|
||||||
|
const UrlParent = styled("div", {
|
||||||
|
base: {
|
||||||
|
display: "flex",
|
||||||
|
width: "100%",
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const UrlTitle = styled("span", {
|
||||||
|
base: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRight: 0,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
borderStyle: "solid",
|
||||||
|
color: theme.color.gray.d900,
|
||||||
|
fontSize: theme.font.size.sm,
|
||||||
|
padding: `0 ${theme.space[3]}`,
|
||||||
|
height: theme.input.size.base,
|
||||||
|
borderColor: theme.color.gray.d400,
|
||||||
|
borderTopLeftRadius: theme.borderRadius,
|
||||||
|
borderBottomLeftRadius: theme.borderRadius,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
export function CreateTeamComponent() {
|
export function CreateTeamComponent() {
|
||||||
const [form, { Form, Field }] = createForm({
|
const [form, { Form, Field }] = createForm({
|
||||||
validate: valiForm(schema),
|
validate: valiForm(schema),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const nav = useNavigate();
|
||||||
|
const auth = useOpenAuth();
|
||||||
|
const account = useAccount();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FullScreen>
|
<FullScreen>
|
||||||
<Container horizontal="center" style={{ width: "100%", padding: "1rem", }} space="1" >
|
<Container horizontal="center" style={{ width: "100%", padding: "1rem", }} space="1" >
|
||||||
@@ -95,20 +127,41 @@ export function CreateTeamComponent() {
|
|||||||
Create a Team
|
Create a Team
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={{ color: theme.color.gray.d900 }} size="sm">
|
<Text style={{ color: theme.color.gray.d900 }} size="sm">
|
||||||
Choose something that your teammates will recognize
|
Choose something that your team mates will recognize
|
||||||
</Text>
|
</Text>
|
||||||
<Hr />
|
<Hr />
|
||||||
</Container>
|
</Container>
|
||||||
<Form style={{ width: "100%", "max-width": "380px" }}>
|
<Form style={{ width: "100%", "max-width": "380px" }}
|
||||||
|
onSubmit={async (data) => {
|
||||||
|
console.log("submitting");
|
||||||
|
const result = await fetch(
|
||||||
|
import.meta.env.VITE_API_URL + "/team",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${await auth.access()}`,
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!result.ok) {
|
||||||
|
setError(form, "slug", "Team slug is already taken.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await account.refresh(account.current.email);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
nav(`/${data.slug}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<FieldList>
|
<FieldList>
|
||||||
<Field type="string" name="slug">
|
<Field type="string" name="name">
|
||||||
{(field, props) => (
|
{(field, props) => (
|
||||||
<FormField
|
<FormField
|
||||||
label="Team Name"
|
label="Team Name"
|
||||||
hint={
|
hint={
|
||||||
field.error
|
field.error
|
||||||
&& field.error
|
&& field.error
|
||||||
// : "Needs to be lowercase, unique, and URL friendly."
|
|
||||||
}
|
}
|
||||||
color={field.error ? "danger" : "primary"}
|
color={field.error ? "danger" : "primary"}
|
||||||
>
|
>
|
||||||
@@ -120,19 +173,47 @@ export function CreateTeamComponent() {
|
|||||||
</FormField>
|
</FormField>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
<Field type="string" name="plan">
|
<Field type="string" name="slug">
|
||||||
|
{(field, props) => (
|
||||||
|
<FormField
|
||||||
|
label="Team Slug"
|
||||||
|
hint={
|
||||||
|
field.error
|
||||||
|
&& field.error
|
||||||
|
}
|
||||||
|
color={field.error ? "danger" : "primary"}
|
||||||
|
>
|
||||||
|
<UrlParent
|
||||||
|
data-type='url'
|
||||||
|
>
|
||||||
|
<UrlTitle>
|
||||||
|
nestri.io/
|
||||||
|
</UrlTitle>
|
||||||
|
<Input
|
||||||
|
{...props}
|
||||||
|
autofocus
|
||||||
|
placeholder={
|
||||||
|
getValue(form, "name")?.toString()
|
||||||
|
.split(" ").join("-")
|
||||||
|
.toLowerCase() || "janes-team"}
|
||||||
|
/>
|
||||||
|
</UrlParent>
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Field type="string" name="planType">
|
||||||
{(field, props) => (
|
{(field, props) => (
|
||||||
<FormField
|
<FormField
|
||||||
label="Plan Type"
|
label="Plan Type"
|
||||||
hint={
|
hint={
|
||||||
field.error
|
field.error
|
||||||
&& field.error
|
&& field.error
|
||||||
// : "Needs to be lowercase, unique, and URL friendly."
|
|
||||||
}
|
}
|
||||||
color={field.error ? "danger" : "primary"}
|
color={field.error ? "danger" : "primary"}
|
||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
{...props}
|
{...props}
|
||||||
|
required
|
||||||
value={field.value}
|
value={field.value}
|
||||||
badges={[
|
badges={[
|
||||||
{ label: "BYOG", color: "purple" },
|
{ label: "BYOG", color: "purple" },
|
||||||
@@ -156,8 +237,10 @@ export function CreateTeamComponent() {
|
|||||||
</div>
|
</div>
|
||||||
</Summary>
|
</Summary>
|
||||||
</Details> */}
|
</Details> */}
|
||||||
<Button color="brand">
|
<Button color="brand" disabled={form.submitting} >
|
||||||
Continue
|
<Show when={form.submitting} fallback="Create">
|
||||||
|
Creating…
|
||||||
|
</Show>
|
||||||
</Button>
|
</Button>
|
||||||
</FieldList>
|
</FieldList>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
70
packages/www/src/pages/not-found.tsx
Normal file
70
packages/www/src/pages/not-found.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { Show } from "solid-js";
|
||||||
|
import { A } from "@solidjs/router";
|
||||||
|
import { Text } from "@nestri/www/ui/text";
|
||||||
|
import { styled } from "@macaron-css/solid";
|
||||||
|
import { theme } from "@nestri/www/ui/theme";
|
||||||
|
import { Header } from "@nestri/www/pages/team/header";
|
||||||
|
import { FullScreen, Container } from "@nestri/www/ui/layout";
|
||||||
|
|
||||||
|
const NotAllowedDesc = styled("div", {
|
||||||
|
base: {
|
||||||
|
fontSize: theme.font.size.base,
|
||||||
|
color: theme.color.gray.d900,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const HomeLink = styled(A, {
|
||||||
|
base: {
|
||||||
|
fontSize: theme.font.size.base,
|
||||||
|
textUnderlineOffset: 1,
|
||||||
|
color: theme.color.blue.d900
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
interface ErrorScreenProps {
|
||||||
|
inset?: "none" | "header";
|
||||||
|
message?: string;
|
||||||
|
header?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotFound(props: ErrorScreenProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Show when={props.header}>
|
||||||
|
<Header />
|
||||||
|
</Show>
|
||||||
|
<FullScreen
|
||||||
|
inset={props.inset ? props.inset : props.header ? "header" : "none"}
|
||||||
|
>
|
||||||
|
<Container space="2.5" horizontal="center">
|
||||||
|
<Text weight="semibold" spacing="xs" size="3xl">{props.message || "Page not found"}</Text>
|
||||||
|
<HomeLink href="/">Go back home</HomeLink>
|
||||||
|
</Container>
|
||||||
|
</FullScreen>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotAllowed(props: ErrorScreenProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Show when={props.header}>
|
||||||
|
<Header />
|
||||||
|
</Show>
|
||||||
|
<FullScreen
|
||||||
|
inset={props.inset ? props.inset : props.header ? "header" : "none"}
|
||||||
|
>
|
||||||
|
<Container space="2.5" horizontal="center">
|
||||||
|
<Text weight="semibold" spacing="xs" size="3xl">Access not allowed</Text>
|
||||||
|
<NotAllowedDesc>
|
||||||
|
You don't have access to this page,
|
||||||
|
<HomeLink href="/">go back home</HomeLink>.
|
||||||
|
</NotAllowedDesc>
|
||||||
|
<NotAllowedDesc>
|
||||||
|
Public profiles are coming soon
|
||||||
|
</NotAllowedDesc>
|
||||||
|
</Container>
|
||||||
|
</FullScreen>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
322
packages/www/src/pages/team/header.tsx
Normal file
322
packages/www/src/pages/team/header.tsx
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
import { A } from "@solidjs/router";
|
||||||
|
import { Container } from "@nestri/www/ui";
|
||||||
|
import Avatar from "@nestri/www/ui/avatar";
|
||||||
|
import { styled } from "@macaron-css/solid";
|
||||||
|
import { theme } from "@nestri/www/ui/theme";
|
||||||
|
import { useAccount } from "@nestri/www/providers/account";
|
||||||
|
import { TeamContext } from "@nestri/www/providers/context";
|
||||||
|
import { Match, ParentProps, Show, Switch, useContext } from "solid-js";
|
||||||
|
|
||||||
|
const PageWrapper = styled("div", {
|
||||||
|
base: {
|
||||||
|
minHeight: "100dvh",
|
||||||
|
// paddingBottom: "4rem",
|
||||||
|
backgroundColor: theme.color.background.d200
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const NestriLogo = styled("svg", {
|
||||||
|
base: {
|
||||||
|
height: 28,
|
||||||
|
width: 28,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const NestriLogoBig = styled("svg", {
|
||||||
|
base: {
|
||||||
|
height: 38,
|
||||||
|
width: 38,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const LineSvg = styled("svg", {
|
||||||
|
base: {
|
||||||
|
width: 26,
|
||||||
|
height: 26,
|
||||||
|
color: theme.color.grayAlpha.d300
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const LogoName = styled("svg", {
|
||||||
|
base: {
|
||||||
|
height: 18,
|
||||||
|
color: theme.color.d1000.grayAlpha
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const Link = styled(A, {
|
||||||
|
base: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 2
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const TeamRoot = styled("div", {
|
||||||
|
base: {
|
||||||
|
height: 32,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 8
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const LogoRoot = styled("div", {
|
||||||
|
base: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const TeamLabel = styled("span", {
|
||||||
|
base: {
|
||||||
|
letterSpacing: -0.5,
|
||||||
|
fontSize: theme.font.size.base,
|
||||||
|
fontFamily: theme.font.family.heading,
|
||||||
|
fontWeight: theme.font.weight.semibold,
|
||||||
|
color: theme.color.gray.d900
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const Badge = styled("div", {
|
||||||
|
base: {
|
||||||
|
height: 20,
|
||||||
|
fontSize: 11,
|
||||||
|
lineHeight: 1,
|
||||||
|
color: "#FFF",
|
||||||
|
padding: "0 6px",
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
borderRadius: 9999,
|
||||||
|
alignItems: "center",
|
||||||
|
display: "inline-flex",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontFeatureSettings: `"tnum"`,
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const DropIcon = styled("svg", {
|
||||||
|
base: {
|
||||||
|
height: 14,
|
||||||
|
width: 14,
|
||||||
|
marginLeft: -4,
|
||||||
|
color: theme.color.grayAlpha.d800
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const AvatarImg = styled("img", {
|
||||||
|
base: {
|
||||||
|
height: 32,
|
||||||
|
width: 32,
|
||||||
|
borderRadius: 9999
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const RightRoot = styled("div", {
|
||||||
|
base: {
|
||||||
|
marginLeft: "auto",
|
||||||
|
display: "flex",
|
||||||
|
gap: theme.space["4"],
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const NavRoot = styled("div", {
|
||||||
|
base: {
|
||||||
|
display: "flex",
|
||||||
|
height: "100%",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: theme.space["4"],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const NavLink = styled(A, {
|
||||||
|
base: {
|
||||||
|
color: "#FFF",
|
||||||
|
textDecoration: "none",
|
||||||
|
height: 32,
|
||||||
|
padding: "0 8px",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
borderRadius: 8,
|
||||||
|
gap: theme.space["2"],
|
||||||
|
lineHeight: 1.5,
|
||||||
|
fontSize: theme.font.size.sm,
|
||||||
|
fontWeight: theme.font.weight.regular,
|
||||||
|
transition: "all 0.3s cubic-bezier(0.4,0,0.2,1)",
|
||||||
|
// ":hover": {
|
||||||
|
// color: theme.color.d1000.gray
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const NavWrapper = styled("div", {
|
||||||
|
base: {
|
||||||
|
// borderBottom: "1px solid white",
|
||||||
|
zIndex: 10,
|
||||||
|
position: "fixed",
|
||||||
|
// backdropFilter: "saturate(60%) blur(3px)",
|
||||||
|
height: theme.headerHeight.root,
|
||||||
|
transition: "all 0.3s cubic-bezier(0.4,0,0.2,1)",
|
||||||
|
width: "100%",
|
||||||
|
backgroundColor: "transparent"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const Background = styled("div", {
|
||||||
|
base: {
|
||||||
|
background: theme.color.headerGradient,
|
||||||
|
zIndex: 1,
|
||||||
|
height: 180,
|
||||||
|
width: "100%",
|
||||||
|
position: "fixed",
|
||||||
|
pointerEvents: "none"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const Nav = styled("nav", {
|
||||||
|
base: {
|
||||||
|
position: "relative",
|
||||||
|
padding: "0.75rem 1rem",
|
||||||
|
zIndex: 200,
|
||||||
|
width: "100%",
|
||||||
|
gap: "1.5rem",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export function Header(props: { whiteColor?: boolean } & ParentProps) {
|
||||||
|
const team = useContext(TeamContext)
|
||||||
|
const account = useAccount()
|
||||||
|
return (
|
||||||
|
<PageWrapper>
|
||||||
|
<NavWrapper style={{ color: props.whiteColor ? "#FFF" : theme.color.d1000.gray }} >
|
||||||
|
{/* <Background /> */}
|
||||||
|
<Nav>
|
||||||
|
<Container space="4" vertical="center">
|
||||||
|
<Show when={team}
|
||||||
|
fallback={
|
||||||
|
<Link href="/">
|
||||||
|
<NestriLogoBig
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
viewBox="0 0 12.8778 9.7377253"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="m 2.093439,1.7855532 h 8.690922 V 2.2639978 H 2.093439 Z m 0,2.8440874 h 8.690922 V 5.1080848 H 2.093439 Z m 0,2.8440866 h 8.690922 V 7.952172 H 2.093439 Z"
|
||||||
|
style="font-size:12px;fill:#ff4f01;fill-opacity:1;fill-rule:evenodd;stroke:#ff4f01;stroke-width:1.66201;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1" />
|
||||||
|
</NestriLogoBig>
|
||||||
|
<LogoName viewBox="0 0 498.05 70.508" xmlns="http://www.w3.org/2000/svg" height="100%" width="100%" >
|
||||||
|
<g stroke-line-cap="round" fill-rule="evenodd" font-size="9pt" fill="currentColor">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
pathLength="1"
|
||||||
|
d="M 261.23 41.65 L 212.402 41.65 Q 195.313 41.65 195.313 27.002 L 195.313 14.795 A 17.814 17.814 0 0 1 196.311 8.57 Q 199.443 0.146 212.402 0.146 L 283.203 0.146 L 283.203 14.844 L 217.236 14.844 Q 215.337 14.844 214.945 16.383 A 3.67 3.67 0 0 0 214.844 17.285 L 214.844 24.561 Q 214.844 27.002 217.236 27.002 L 266.113 27.002 Q 283.203 27.002 283.203 41.65 L 283.203 53.857 A 17.814 17.814 0 0 1 282.205 60.083 Q 279.073 68.506 266.113 68.506 L 195.313 68.506 L 195.313 53.809 L 261.23 53.809 A 3.515 3.515 0 0 0 262.197 53.688 Q 263.672 53.265 263.672 51.367 L 263.672 44.092 A 3.515 3.515 0 0 0 263.551 43.126 Q 263.128 41.65 261.23 41.65 Z M 185.547 53.906 L 185.547 68.506 L 114.746 68.506 Q 97.656 68.506 97.656 53.857 L 97.656 14.795 A 17.814 17.814 0 0 1 98.655 8.57 Q 101.787 0.146 114.746 0.146 L 168.457 0.146 Q 185.547 0.146 185.547 14.795 L 185.547 31.885 A 17.827 17.827 0 0 1 184.544 38.124 Q 181.621 45.972 170.174 46.538 A 36.906 36.906 0 0 1 168.457 46.582 L 117.188 46.582 L 117.236 51.465 Q 117.236 53.906 119.629 53.955 L 185.547 53.906 Z M 19.531 14.795 L 19.531 68.506 L 0 68.506 L 0 0.146 L 70.801 0.146 Q 87.891 0.146 87.891 14.795 L 87.891 68.506 L 68.359 68.506 L 68.359 17.236 Q 68.359 14.795 65.967 14.795 L 19.531 14.795 Z M 449.219 68.506 L 430.176 46.533 L 400.391 46.533 L 400.391 68.506 L 380.859 68.506 L 380.859 0.146 L 451.66 0.146 A 24.602 24.602 0 0 1 458.423 0.994 Q 466.007 3.166 468.021 10.907 A 25.178 25.178 0 0 1 468.75 17.236 L 468.75 31.885 A 18.217 18.217 0 0 1 467.887 37.73 Q 465.954 43.444 459.698 45.455 A 23.245 23.245 0 0 1 454.492 46.436 L 473.633 68.506 L 449.219 68.506 Z M 292.969 0 L 371.094 0.098 L 371.094 14.795 L 341.846 14.795 L 341.846 68.506 L 322.266 68.506 L 322.217 14.795 L 292.969 14.844 L 292.969 0 Z M 478.516 0.146 L 498.047 0.146 L 498.047 68.506 L 478.516 68.506 L 478.516 0.146 Z M 400.391 14.844 L 400.391 31.885 L 446.826 31.885 Q 448.726 31.885 449.117 30.345 A 3.67 3.67 0 0 0 449.219 29.443 L 449.219 17.285 Q 449.219 14.844 446.826 14.844 L 400.391 14.844 Z M 117.188 31.836 L 163.574 31.934 Q 165.528 31.895 165.918 30.355 A 3.514 3.514 0 0 0 166.016 29.492 L 166.016 17.236 Q 166.016 15.337 164.476 14.945 A 3.67 3.67 0 0 0 163.574 14.844 L 119.629 14.795 Q 117.188 14.795 117.188 17.188 L 117.188 31.836 Z" />
|
||||||
|
</g>
|
||||||
|
</LogoName>
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LogoRoot>
|
||||||
|
<A href={`/${team!().slug}`} >
|
||||||
|
<NestriLogo
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
viewBox="0 0 12.8778 9.7377253"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="m 2.093439,1.7855532 h 8.690922 V 2.2639978 H 2.093439 Z m 0,2.8440874 h 8.690922 V 5.1080848 H 2.093439 Z m 0,2.8440866 h 8.690922 V 7.952172 H 2.093439 Z"
|
||||||
|
style="font-size:12px;fill:#ff4f01;fill-opacity:1;fill-rule:evenodd;stroke:#ff4f01;stroke-width:1.66201;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1" />
|
||||||
|
</NestriLogo>
|
||||||
|
</A>
|
||||||
|
<LineSvg
|
||||||
|
height="16"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
width="16">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
d="M4.01526 15.3939L4.3107 14.7046L10.3107 0.704556L10.6061 0.0151978L11.9849 0.606077L11.6894 1.29544L5.68942 15.2954L5.39398 15.9848L4.01526 15.3939Z" fill="currentColor"></path>
|
||||||
|
</LineSvg>
|
||||||
|
<TeamRoot>
|
||||||
|
<Avatar size={21} name={team!().slug} />
|
||||||
|
<TeamLabel style={{ color: props.whiteColor ? "#FFF" : theme.color.d1000.gray }}>{team!().name}</TeamLabel>
|
||||||
|
<Switch>
|
||||||
|
<Match when={team!().planType === "BYOG"}>
|
||||||
|
<Badge style={{ "background-color": theme.color.purple.d700 }}>
|
||||||
|
<span style={{ "line-height": 0 }} >BYOG</span>
|
||||||
|
</Badge>
|
||||||
|
</Match>
|
||||||
|
<Match when={team!().planType === "Hosted"}>
|
||||||
|
<Badge style={{ "background-color": theme.color.blue.d700 }}>
|
||||||
|
<span style={{ "line-height": 0 }}>Hosted</span>
|
||||||
|
</Badge>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
<DropIcon
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="32"
|
||||||
|
height="32"
|
||||||
|
viewBox="0 0 256 256">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M72.61 83.06a8 8 0 0 1 1.73-8.72l48-48a8 8 0 0 1 11.32 0l48 48A8 8 0 0 1 176 88H80a8 8 0 0 1-7.39-4.94M176 168H80a8 8 0 0 0-5.66 13.66l48 48a8 8 0 0 0 11.32 0l48-48A8 8 0 0 0 176 168" />
|
||||||
|
</DropIcon>
|
||||||
|
</TeamRoot>
|
||||||
|
</LogoRoot>
|
||||||
|
</Show>
|
||||||
|
</Container>
|
||||||
|
<RightRoot>
|
||||||
|
<Show when={team}>
|
||||||
|
<NavRoot>
|
||||||
|
<NavLink href={`/${team!().slug}/machines`}>
|
||||||
|
{/* <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24">
|
||||||
|
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17.5 17.5L22 22m-2-11a9 9 0 1 0-18 0a9 9 0 0 0 18 0" color="currentColor" />
|
||||||
|
</svg> */}
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24">
|
||||||
|
<path fill="currentColor" d="M3.441 9.956a4.926 4.926 0 0 0 6.233 7.571l4.256 4.257a.773.773 0 0 0 1.169-1.007l-.075-.087l-4.217-4.218A4.927 4.927 0 0 0 3.44 9.956m13.213 6.545c-.225 1.287-.548 2.456-.952 3.454l.03.028l.124.14c.22.295.344.624.378.952a10.03 10.03 0 0 0 4.726-4.574zM12.25 16.5l2.284 2.287c.202-.6.381-1.268.53-1.992l.057-.294zm-2.936-5.45a3.38 3.38 0 1 1-4.78 4.779a3.38 3.38 0 0 1 4.78-4.78M15.45 10h-3.7a5.94 5.94 0 0 1 .892 5h2.71a26 26 0 0 0 .132-4.512zm1.507 0a28 28 0 0 1-.033 4.42l-.057.58h4.703a10.05 10.05 0 0 0 .258-5zm-2.095-7.593c.881 1.35 1.536 3.329 1.883 5.654l.062.44h4.59a10.03 10.03 0 0 0-6.109-5.958l-.304-.1zm-2.836-.405c-1.277 0-2.561 2.382-3.158 5.839c.465.16.912.38 1.331.658l5.088.001c-.54-3.809-1.905-6.498-3.261-6.498m-2.837.405A10.03 10.03 0 0 0 2.654 8.5h.995a5.92 5.92 0 0 1 3.743-.968c.322-1.858.846-3.47 1.527-4.68l.162-.275z" />
|
||||||
|
</svg>
|
||||||
|
{/* Machines */}
|
||||||
|
</NavLink>
|
||||||
|
<NavLink href={`/${team!().slug}/machines`}>
|
||||||
|
<svg style={{ "margin-bottom": "1px" }} xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 16 16">
|
||||||
|
<g fill="currentColor"><path d="M4 8a1.5 1.5 0 1 1 3 0a1.5 1.5 0 0 1-3 0m7.5-1.5a1.5 1.5 0 1 0 0 3a1.5 1.5 0 0 0 0-3" />
|
||||||
|
<path d="M0 1.5A.5.5 0 0 1 .5 1h1a.5.5 0 0 1 .5.5V4h13.5a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-.5.5H2v2.5a.5.5 0 0 1-1 0V2H.5a.5.5 0 0 1-.5-.5m5.5 4a2.5 2.5 0 1 0 0 5a2.5 2.5 0 0 0 0-5M9 8a2.5 2.5 0 1 0 5 0a2.5 2.5 0 0 0-5 0" />
|
||||||
|
<path d="M3 12.5h3.5v1a.5.5 0 0 1-.5.5H3.5a.5.5 0 0 1-.5-.5zm4 1v-1h4v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</NavLink>
|
||||||
|
</NavRoot>
|
||||||
|
</Show>
|
||||||
|
<div style={{ "margin-bottom": "2px" }} >
|
||||||
|
<Switch>
|
||||||
|
<Match when={account.current.avatarUrl} >
|
||||||
|
<AvatarImg src={account.current.avatarUrl} alt={`${account.current.name}'s avatar`} />
|
||||||
|
</Match>
|
||||||
|
<Match when={!account.current.avatarUrl}>
|
||||||
|
<Avatar size={32} name={`${account.current.name}#${account.current.discriminator}`} />
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
</div>
|
||||||
|
</RightRoot>
|
||||||
|
</Nav>
|
||||||
|
</NavWrapper>
|
||||||
|
{props.children}
|
||||||
|
</PageWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
414
packages/www/src/pages/team/home.tsx
Normal file
414
packages/www/src/pages/team/home.tsx
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
import { FullScreen, theme } from "@nestri/www/ui";
|
||||||
|
import { styled } from "@macaron-css/solid";
|
||||||
|
import { Header } from "@nestri/www/pages/team/header";
|
||||||
|
import { useSteam } from "@nestri/www/providers/steam";
|
||||||
|
import { Modal } from "@nestri/www/ui/modal";
|
||||||
|
import { createEffect, createSignal, onCleanup } from "solid-js";
|
||||||
|
import { Text } from "@nestri/www/ui/text"
|
||||||
|
import { QRCode } from "@nestri/www/ui/custom-qr";
|
||||||
|
import { globalStyle, keyframes } from "@macaron-css/core";
|
||||||
|
import { A } from "@solidjs/router";
|
||||||
|
|
||||||
|
const EmptyState = styled("div", {
|
||||||
|
base: {
|
||||||
|
padding: "0 40px",
|
||||||
|
display: "flex",
|
||||||
|
gap: 10,
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
margin: "auto"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const EmptyStateHeader = styled("h2", {
|
||||||
|
base: {
|
||||||
|
textAlign: "center",
|
||||||
|
fontSize: theme.font.size["2xl"],
|
||||||
|
fontFamily: theme.font.family.heading,
|
||||||
|
fontWeight: theme.font.weight.semibold,
|
||||||
|
letterSpacing: -0.5,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const EmptyStateSubHeader = styled("p", {
|
||||||
|
base: {
|
||||||
|
fontWeight: theme.font.weight.regular,
|
||||||
|
color: theme.color.gray.d900,
|
||||||
|
fontSize: theme.font.size["lg"],
|
||||||
|
textAlign: "center",
|
||||||
|
maxWidth: 380,
|
||||||
|
letterSpacing: -0.4,
|
||||||
|
lineHeight: 1.1,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const QRWrapper = styled("div", {
|
||||||
|
base: {
|
||||||
|
backgroundColor: theme.color.background.d100,
|
||||||
|
position: "relative",
|
||||||
|
marginBottom: 20,
|
||||||
|
textWrap: "balance",
|
||||||
|
border: `1px solid ${theme.color.gray.d400}`,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
overflow: "hidden",
|
||||||
|
borderRadius: 22,
|
||||||
|
padding: 20,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const SteamMobileLink = styled(A, {
|
||||||
|
base: {
|
||||||
|
textUnderlineOffset: 2,
|
||||||
|
textDecoration: "none",
|
||||||
|
color: theme.color.blue.d900,
|
||||||
|
display: "inline-flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 1,
|
||||||
|
width: "max-content",
|
||||||
|
textTransform: "capitalize",
|
||||||
|
":hover": {
|
||||||
|
textDecoration: "underline"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const LogoContainer = styled("div", {
|
||||||
|
base: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const LogoIcon = styled("svg", {
|
||||||
|
base: {
|
||||||
|
zIndex: 6,
|
||||||
|
position: "absolute",
|
||||||
|
left: "50%",
|
||||||
|
top: "50%",
|
||||||
|
transform: "translate(-50%,-50%)",
|
||||||
|
overflow: "hidden",
|
||||||
|
// width: "21%",
|
||||||
|
// height: "21%",
|
||||||
|
borderRadius: 17,
|
||||||
|
// ":before": {
|
||||||
|
// pointerEvents: "none",
|
||||||
|
// zIndex: 2,
|
||||||
|
// content: '',
|
||||||
|
// position: "absolute",
|
||||||
|
// inset: 0,
|
||||||
|
// borderRadius: "inherit",
|
||||||
|
// boxShadow: "inset 0 0 0 1px rgba(0, 0, 0, 0.02)",
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const LastPlayedWrapper = styled("div", {
|
||||||
|
base: {
|
||||||
|
position: "relative",
|
||||||
|
width: "100%",
|
||||||
|
justifyContent: "center",
|
||||||
|
minHeight: 700,
|
||||||
|
height: "50vw",
|
||||||
|
maxHeight: 800,
|
||||||
|
WebkitBoxPack: "center",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
":after": {
|
||||||
|
content: "",
|
||||||
|
pointerEvents: "none",
|
||||||
|
userSelect: "none",
|
||||||
|
background: `linear-gradient(to bottom,transparent,${theme.color.background.d200})`,
|
||||||
|
width: "100%",
|
||||||
|
left: 0,
|
||||||
|
position: "absolute",
|
||||||
|
bottom: -1,
|
||||||
|
zIndex: 3,
|
||||||
|
height: 320,
|
||||||
|
backdropFilter: "blur(2px)",
|
||||||
|
WebkitBackdropFilter: "blur(1px)",
|
||||||
|
WebkitMaskImage: `linear-gradient(to top,${theme.color.background.d200} 25%,transparent)`,
|
||||||
|
maskImage: `linear-gradient(to top,${theme.color.background.d200} 25%,transparent)`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const LastPlayedFader = styled("div", {
|
||||||
|
base: {
|
||||||
|
position: "absolute",
|
||||||
|
width: "100%",
|
||||||
|
height: "3rem",
|
||||||
|
backgroundColor: "rgba(0,0,0,.08)",
|
||||||
|
mixBlendMode: "multiply",
|
||||||
|
backdropFilter: "saturate(160%) blur(60px)",
|
||||||
|
WebkitBackdropFilter: "saturate(160%) blur(60px)",
|
||||||
|
maskImage: "linear-gradient(to top,rgba(0,0,0,.15) 0%,rgba(0,0,0,.65) 57.14%,rgba(0,0,0,.9) 67.86%,#000 79.08%)",
|
||||||
|
// background: "linear-gradient(rgb(0, 0, 0) 0%, rgba(0, 0, 0, 0.3) 50%, rgba(10, 0, 0, 0.15) 65%, rgba(0, 0, 0, 0.075) 75.5%, rgba(0, 0, 0, 0.035) 82.85%, rgba(0, 0, 0, 0.02) 88%, rgba(0, 0, 0, 0) 100%)",
|
||||||
|
opacity: 0.6,
|
||||||
|
// backdropFilter: "blur(16px)",
|
||||||
|
pointerEvents: "none",
|
||||||
|
zIndex: 1,
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const BackgroundImage = styled("div", {
|
||||||
|
base: {
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
backgroundColor: theme.color.background.d200,
|
||||||
|
backgroundSize: "cover",
|
||||||
|
zIndex: 0,
|
||||||
|
transitionDuration: "0.2s",
|
||||||
|
transitionTimingFunction: "cubic-bezier(0.4,0,0.2,1)",
|
||||||
|
transitionProperty: "opacity",
|
||||||
|
backgroundImage: "url(https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/1203190/ss_97ea9b0b5a6adf3436b31d389cd18d3a647ee4bf.jpg)"
|
||||||
|
// backgroundImage: "url(https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/3373660/c4993923f605b608939536b5f2521913850b028a/ss_c4993923f605b608939536b5f2521913850b028a.jpg)"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const LogoBackgroundImage = styled("div", {
|
||||||
|
base: {
|
||||||
|
position: "fixed",
|
||||||
|
top: "2rem",
|
||||||
|
height: 240,
|
||||||
|
// width: 320,
|
||||||
|
aspectRatio: "16 / 9",
|
||||||
|
left: "50%",
|
||||||
|
transform: "translate(-50%,0%)",
|
||||||
|
backgroundSize: "cover",
|
||||||
|
zIndex: 1,
|
||||||
|
transitionDuration: "0.2s",
|
||||||
|
transitionTimingFunction: "cubic-bezier(0.4,0,0.2,1)",
|
||||||
|
transitionProperty: "opacity",
|
||||||
|
backgroundImage: "url(https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/1203190/logo_2x.png)"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const Material = styled("div", {
|
||||||
|
base: {
|
||||||
|
backdropFilter: "saturate(160%) blur(60px)",
|
||||||
|
WebkitBackdropFilter: "saturate(160%) blur(60px)",
|
||||||
|
backgroundSize: "cover",
|
||||||
|
backgroundRepeat: "no-repeat",
|
||||||
|
position: "absolute",
|
||||||
|
borderRadius: 6,
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
height: "100%",
|
||||||
|
width: "100%",
|
||||||
|
maskImage: "linear-gradient(180deg,rgba(0,0,0,0) 0,rgba(0,0,0,0) 40.82%,rgba(0,0,0,.15) 50%,rgba(0,0,0,.65) 57.14%,rgba(0,0,0,.9) 67.86%,#000 79.08%)",
|
||||||
|
WebkitMaskImage: "linear-gradient(180deg,rgba(0,0,0,0) 0,rgba(0,0,0,0) 40.82%,rgba(0,0,0,.15) 50%,rgba(0,0,0,.65) 57.14%,rgba(0,0,0,.9) 67.86%,#000 79.08%)"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const JoeColor = styled("div", {
|
||||||
|
base: {
|
||||||
|
backgroundColor: "rgba(0,0,0,.08)",
|
||||||
|
mixBlendMode: "multiply",
|
||||||
|
position: "absolute",
|
||||||
|
borderRadius: 6,
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
height: "100%",
|
||||||
|
width: "100%",
|
||||||
|
maskImage: "linear-gradient(180deg,rgba(0,0,0,0) 0,rgba(0,0,0,0) 40.82%,rgba(0,0,0,.15) 50%,rgba(0,0,0,.65) 57.14%,rgba(0,0,0,.9) 67.86%,#000 79.08%)",
|
||||||
|
WebkitMaskImage: "linear-gradient(180deg,rgba(0,0,0,0) 0,rgba(0,0,0,0) 40.82%,rgba(0,0,0,.15) 50%,rgba(0,0,0,.65) 57.14%,rgba(0,0,0,.9) 67.86%,#000 79.08%)"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const GamesContainer = styled("div", {
|
||||||
|
base: {
|
||||||
|
width: "100%",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
flexDirection: "column",
|
||||||
|
zIndex: 3,
|
||||||
|
backgroundColor: theme.color.background.d200,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const GamesWrapper = styled("div", {
|
||||||
|
base: {
|
||||||
|
maxWidth: "70vw",
|
||||||
|
width: "100%",
|
||||||
|
gridTemplateColumns: "repeat(4, minmax(0, 1fr))",
|
||||||
|
margin: "0 auto",
|
||||||
|
display: "grid",
|
||||||
|
marginTop: -80,
|
||||||
|
columnGap: 12,
|
||||||
|
rowGap: 10
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const GameImage = styled("img", {
|
||||||
|
base: {
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
aspectRatio: "460/215",
|
||||||
|
borderRadius: 10,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const GameSquareImage = styled("img", {
|
||||||
|
base: {
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
aspectRatio: "1/1",
|
||||||
|
borderRadius: 10,
|
||||||
|
transitionDuration: "0.2s",
|
||||||
|
transitionTimingFunction: "cubic-bezier(0.4,0,0.2,1)",
|
||||||
|
transitionProperty: "all",
|
||||||
|
cursor: "pointer",
|
||||||
|
border: `2px solid transparent`,
|
||||||
|
":hover": {
|
||||||
|
transform: "scale(1.05)",
|
||||||
|
outline: `2px solid ${theme.color.brand}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const GameImageCapsule = styled("img", {
|
||||||
|
base: {
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
aspectRatio: "374/448",
|
||||||
|
borderRadius: 10,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const SteamLibrary = styled("div", {
|
||||||
|
base: {
|
||||||
|
borderTop: `1px solid ${theme.color.gray.d400}`,
|
||||||
|
padding: "20px 0",
|
||||||
|
margin: "20px auto",
|
||||||
|
width: "100%",
|
||||||
|
display: "grid",
|
||||||
|
// backgroundColor: "red",
|
||||||
|
maxWidth: "70vw",
|
||||||
|
gridTemplateColumns: "repeat(4, minmax(0, 1fr))",
|
||||||
|
columnGap: 12,
|
||||||
|
rowGap: 10,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const SteamLibraryTitle = styled("h3", {
|
||||||
|
base: {
|
||||||
|
textAlign: "left",
|
||||||
|
fontFamily: theme.font.family.heading,
|
||||||
|
fontWeight: theme.font.weight.medium,
|
||||||
|
fontSize: theme.font.size["2xl"],
|
||||||
|
letterSpacing: -0.7,
|
||||||
|
gridColumn: "1/-1",
|
||||||
|
marginBottom: 20,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export function HomeRoute() {
|
||||||
|
|
||||||
|
// const steam = useSteam();
|
||||||
|
// const [loginUrl, setLoginUrl] = createSignal<string | null>(null);
|
||||||
|
// const [loginStatus, setLoginStatus] = createSignal<string | null>("Not connected");
|
||||||
|
// const [userData, setUserData] = createSignal<{ username?: string, steamId?: string } | null>(null);
|
||||||
|
|
||||||
|
// createEffect(async () => {
|
||||||
|
// // Connect to the Steam login stream
|
||||||
|
// const steamConnection = await steam.client.login.connect();
|
||||||
|
|
||||||
|
// // Set up event listeners for different event types
|
||||||
|
// const urlUnsubscribe = steamConnection.addEventListener('url', (url) => {
|
||||||
|
// setLoginUrl(url);
|
||||||
|
// setLoginStatus('Scan QR code with Steam mobile app');
|
||||||
|
// });
|
||||||
|
|
||||||
|
// const loginAttemptUnsubscribe = steamConnection.addEventListener('login-attempt', (data) => {
|
||||||
|
// setLoginStatus(`Logging in as ${data.username}...`);
|
||||||
|
// });
|
||||||
|
|
||||||
|
// const loginSuccessUnsubscribe = steamConnection.addEventListener('login-success', (data) => {
|
||||||
|
// setUserData(data);
|
||||||
|
// setLoginStatus(`Successfully logged in as ${data.username}`);
|
||||||
|
// });
|
||||||
|
|
||||||
|
// const loginUnsuccessfulUnsubscribe = steamConnection.addEventListener('login-unsuccessful', (data) => {
|
||||||
|
// setLoginStatus(`Login failed: ${data.error}`);
|
||||||
|
// });
|
||||||
|
|
||||||
|
// const loggedOffUnsubscribe = steamConnection.addEventListener('logged-off', (data) => {
|
||||||
|
// setLoginStatus(`Logged out of Steam: ${data.reason}`);
|
||||||
|
// setUserData(null);
|
||||||
|
// });
|
||||||
|
|
||||||
|
// onCleanup(() => {
|
||||||
|
// urlUnsubscribe();
|
||||||
|
// loginAttemptUnsubscribe();
|
||||||
|
// loginSuccessUnsubscribe();
|
||||||
|
// loginUnsuccessfulUnsubscribe();
|
||||||
|
// loggedOffUnsubscribe();
|
||||||
|
// steamConnection.disconnect();
|
||||||
|
// });
|
||||||
|
// })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header whiteColor>
|
||||||
|
<FullScreen >
|
||||||
|
<EmptyState
|
||||||
|
style={{
|
||||||
|
"--nestri-qr-dot-color": theme.color.d1000.gray,
|
||||||
|
"--nestri-body-background": theme.color.gray.d100
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<QRWrapper>
|
||||||
|
<LogoContainer>
|
||||||
|
<LogoIcon
|
||||||
|
xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 16 16">
|
||||||
|
<g fill="currentColor">
|
||||||
|
<path d="M.329 10.333A8.01 8.01 0 0 0 7.99 16C12.414 16 16 12.418 16 8s-3.586-8-8.009-8A8.006 8.006 0 0 0 0 7.468l.003.006l4.304 1.769A2.2 2.2 0 0 1 5.62 8.88l1.96-2.844l-.001-.04a3.046 3.046 0 0 1 3.042-3.043a3.046 3.046 0 0 1 3.042 3.043a3.047 3.047 0 0 1-3.111 3.044l-2.804 2a2.223 2.223 0 0 1-3.075 2.11a2.22 2.22 0 0 1-1.312-1.568L.33 10.333Z" /><path d="M4.868 12.683a1.715 1.715 0 0 0 1.318-3.165a1.7 1.7 0 0 0-1.263-.02l1.023.424a1.261 1.261 0 1 1-.97 2.33l-.99-.41a1.7 1.7 0 0 0 .882.84Zm3.726-6.687a2.03 2.03 0 0 0 2.027 2.029a2.03 2.03 0 0 0 2.027-2.029a2.03 2.03 0 0 0-2.027-2.027a2.03 2.03 0 0 0-2.027 2.027m2.03-1.527a1.524 1.524 0 1 1-.002 3.048a1.524 1.524 0 0 1 .002-3.048" />
|
||||||
|
</g>
|
||||||
|
</LogoIcon>
|
||||||
|
</LogoContainer>
|
||||||
|
<QRCode
|
||||||
|
uri={"https://github.com/family/connectkit/blob/9a3c16c781d8a60853eff0c4988e22926a3f91ce"}
|
||||||
|
size={180}
|
||||||
|
ecl="M"
|
||||||
|
clearArea={true}
|
||||||
|
/>
|
||||||
|
</QRWrapper>
|
||||||
|
<EmptyStateHeader>Sign in to your Steam account</EmptyStateHeader>
|
||||||
|
<EmptyStateSubHeader>Use your Steam Mobile App to sign in via QR code. <SteamMobileLink href="https://store.steampowered.com/mobile" target="_blank">Learn More<svg data-testid="geist-icon" height="20" stroke-linejoin="round" viewBox="0 0 16 16" width="20" style="color: currentcolor;"><path fill-rule="evenodd" clip-rule="evenodd" d="M11.5 9.75V11.25C11.5 11.3881 11.3881 11.5 11.25 11.5H4.75C4.61193 11.5 4.5 11.3881 4.5 11.25L4.5 4.75C4.5 4.61193 4.61193 4.5 4.75 4.5H6.25H7V3H6.25H4.75C3.7835 3 3 3.7835 3 4.75V11.25C3 12.2165 3.7835 13 4.75 13H11.25C12.2165 13 13 12.2165 13 11.25V9.75V9H11.5V9.75ZM8.5 3H9.25H12.2495C12.6637 3 12.9995 3.33579 12.9995 3.75V6.75V7.5H11.4995V6.75V5.56066L8.53033 8.52978L8 9.06011L6.93934 7.99945L7.46967 7.46912L10.4388 4.5H9.25H8.5V3Z" fill="currentColor"></path></svg></SteamMobileLink></EmptyStateSubHeader>
|
||||||
|
</EmptyState>
|
||||||
|
{/* <LastPlayedWrapper>
|
||||||
|
<LastPlayedFader />
|
||||||
|
<LogoBackgroundImage />
|
||||||
|
<BackgroundImage />
|
||||||
|
<Material />
|
||||||
|
<JoeColor />
|
||||||
|
</LastPlayedWrapper> */}
|
||||||
|
{/* <GamesContainer>
|
||||||
|
<GamesWrapper>
|
||||||
|
<GameSquareImage alt="Assasin's Creed Shadows" src="https://assets-prd.ignimgs.com/2024/05/15/acshadows-1715789601294.jpg" />
|
||||||
|
<GameSquareImage alt="Assasin's Creed Shadows" src="https://assets-prd.ignimgs.com/2022/09/22/slime-rancher-2-button-02-1663890048548.jpg" />
|
||||||
|
<GameSquareImage alt="Assasin's Creed Shadows" src="https://assets-prd.ignimgs.com/2023/05/19/cataclismo-button-1684532710313.jpg" />
|
||||||
|
<GameSquareImage alt="Assasin's Creed Shadows" src="https://assets-prd.ignimgs.com/2024/03/27/marvelrivals-1711557092104.jpg" />
|
||||||
|
</GamesWrapper>
|
||||||
|
<SteamLibrary>
|
||||||
|
<SteamLibraryTitle>Games we think you will like</SteamLibraryTitle>
|
||||||
|
<GameImageCapsule alt="Assasin's Creed Shadows" src="https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/2625420/hero_capsule.jpg?t=1742853642" />
|
||||||
|
<GameImageCapsule alt="Assasin's Creed Shadows" src="https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/2486740/hero_capsule.jpg?t=1742596243" />
|
||||||
|
<GameImageCapsule alt="Assasin's Creed Shadows" src="https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/870780/hero_capsule.jpg?t=1737800535" />
|
||||||
|
<GameImageCapsule alt="Assasin's Creed Shadows" src="https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/2050650/hero_capsule.jpg?t=1737800535" />
|
||||||
|
</SteamLibrary>
|
||||||
|
</GamesContainer> */}
|
||||||
|
</FullScreen>
|
||||||
|
</Header>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
69
packages/www/src/pages/team/index.tsx
Normal file
69
packages/www/src/pages/team/index.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { HomeRoute } from "./home";
|
||||||
|
import { useOpenAuth } from "@openauthjs/solid";
|
||||||
|
import { Route, useParams } from "@solidjs/router";
|
||||||
|
import { ApiProvider } from "@nestri/www/providers/api";
|
||||||
|
import { SteamRoute } from "@nestri/www/pages/team/steam";
|
||||||
|
import { ZeroProvider } from "@nestri/www/providers/zero";
|
||||||
|
import { TeamContext } from "@nestri/www/providers/context";
|
||||||
|
import { SteamProvider } from "@nestri/www/providers/steam";
|
||||||
|
import { createEffect, createMemo, Match, Switch } from "solid-js";
|
||||||
|
import { NotAllowed, NotFound } from "@nestri/www/pages/not-found";
|
||||||
|
import { useAccount, useStorage } from "@nestri/www/providers/account";
|
||||||
|
|
||||||
|
export const TeamRoute = (
|
||||||
|
<Route
|
||||||
|
component={(props) => {
|
||||||
|
const params = useParams();
|
||||||
|
const account = useAccount();
|
||||||
|
const storage = useStorage();
|
||||||
|
const openauth = useOpenAuth();
|
||||||
|
|
||||||
|
const team = createMemo(() =>
|
||||||
|
account.current.teams.find(
|
||||||
|
(item) => item.slug === params.teamSlug,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const t = team();
|
||||||
|
if (!t) return;
|
||||||
|
storage.set("team", t.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const teamSlug = params.teamSlug;
|
||||||
|
for (const item of Object.values(account.all)) {
|
||||||
|
for (const team of item.teams) {
|
||||||
|
if (team.slug === teamSlug && item.id !== openauth.subject!.id) {
|
||||||
|
openauth.switch(item.email);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Switch>
|
||||||
|
<Match when={!team()}>
|
||||||
|
TODO: Add a public page for (other) teams
|
||||||
|
<NotAllowed header />
|
||||||
|
</Match>
|
||||||
|
<Match when={team()}>
|
||||||
|
<TeamContext.Provider value={() => team()!}>
|
||||||
|
<ZeroProvider>
|
||||||
|
<ApiProvider>
|
||||||
|
<SteamProvider>
|
||||||
|
{props.children}
|
||||||
|
</SteamProvider>
|
||||||
|
</ApiProvider>
|
||||||
|
</ZeroProvider>
|
||||||
|
</TeamContext.Provider>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Route path="" component={HomeRoute} />
|
||||||
|
<Route path="steam" component={SteamRoute} />
|
||||||
|
<Route path="*" component={() => <NotFound header />} />
|
||||||
|
</Route>
|
||||||
|
)
|
||||||
238
packages/www/src/pages/team/steam.tsx
Normal file
238
packages/www/src/pages/team/steam.tsx
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import { Header } from "./header"
|
||||||
|
import { theme } from "@nestri/www/ui";
|
||||||
|
import { Text } from "@nestri/www/ui";
|
||||||
|
import { styled } from "@macaron-css/solid";
|
||||||
|
import { useSteam } from "@nestri/www/providers/steam";
|
||||||
|
import { createEffect, onCleanup } from "solid-js";
|
||||||
|
|
||||||
|
// FIXME: Remove this route, or move it to machines
|
||||||
|
|
||||||
|
// The idea has changed, let the user login to Steam from the / route
|
||||||
|
// Let the machines route remain different from the main page
|
||||||
|
// Why? It becomes much simpler for routing and onboarding, plus how often will you move to the machines route?
|
||||||
|
// Now it will be the home page's problem with making sure the user can download and install games on whatever machine they need/want
|
||||||
|
|
||||||
|
const Root = styled("div", {
|
||||||
|
base: {
|
||||||
|
display: "grid",
|
||||||
|
gridAutoRows: "1fr",
|
||||||
|
position: "relative",
|
||||||
|
gridTemplateRows: "0 auto",
|
||||||
|
backgroundColor: theme.color.background.d200,
|
||||||
|
minHeight: `calc(100vh - ${theme.headerHeight.root})`,
|
||||||
|
gridTemplateColumns: "minmax(24px,1fr) minmax(0,1000px) minmax(24px,1fr)"
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const Section = styled("section", {
|
||||||
|
base: {
|
||||||
|
gridColumn: "1/-1",
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const TitleHeader = styled("header", {
|
||||||
|
base: {
|
||||||
|
borderBottom: `1px solid ${theme.color.gray.d400}`,
|
||||||
|
color: theme.color.d1000.gray
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const TitleWrapper = styled("div", {
|
||||||
|
base: {
|
||||||
|
width: "calc(1000px + calc(2 * 24px))",
|
||||||
|
paddingLeft: "24px",
|
||||||
|
display: "flex",
|
||||||
|
paddingRight: "24px",
|
||||||
|
marginLeft: "auto",
|
||||||
|
marginRight: "auto",
|
||||||
|
maxWidth: "100%"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const TitleContainer = styled("div", {
|
||||||
|
base: {
|
||||||
|
margin: "40px 0",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 16,
|
||||||
|
width: "100%",
|
||||||
|
minWidth: 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const ButtonContainer = styled("div", {
|
||||||
|
base: {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 16,
|
||||||
|
margin: "40px 0",
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const Title = styled("h1", {
|
||||||
|
base: {
|
||||||
|
lineHeight: "2.5rem",
|
||||||
|
fontWeight: theme.font.weight.semibold,
|
||||||
|
letterSpacing: "-0.069375rem",
|
||||||
|
fontSize: theme.font.size["4xl"],
|
||||||
|
textTransform: "capitalize"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const Description = styled("p", {
|
||||||
|
base: {
|
||||||
|
fontSize: theme.font.size.sm,
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
fontWeight: theme.font.weight.regular,
|
||||||
|
letterSpacing: "initial",
|
||||||
|
color: theme.color.gray.d900
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const QRButton = styled("button", {
|
||||||
|
base: {
|
||||||
|
height: 40,
|
||||||
|
borderRadius: theme.borderRadius,
|
||||||
|
backgroundColor: theme.color.d1000.gray,
|
||||||
|
color: theme.color.gray.d100,
|
||||||
|
fontSize: theme.font.size.sm,
|
||||||
|
textWrap: "nowrap",
|
||||||
|
border: "1px solid transparent",
|
||||||
|
padding: `${theme.space[2]} ${theme.space[4]}`,
|
||||||
|
letterSpacing: 0.1,
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
fontFamily: theme.font.family.body,
|
||||||
|
fontWeight: theme.font.weight.medium,
|
||||||
|
cursor: "pointer",
|
||||||
|
transitionDelay: "0s, 0s",
|
||||||
|
transitionDuration: "0.2s, 0.2s",
|
||||||
|
transitionProperty: "background-color, border",
|
||||||
|
transitionTimingFunction: "ease-out, ease-out",
|
||||||
|
display: "inline-flex",
|
||||||
|
gap: theme.space[2],
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
":disabled": {
|
||||||
|
pointerEvents: "none",
|
||||||
|
},
|
||||||
|
":hover": {
|
||||||
|
background: theme.color.hoverColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const ButtonText = styled("span", {
|
||||||
|
base: {
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const Body = styled("div", {
|
||||||
|
base: {
|
||||||
|
padding: "0 24px",
|
||||||
|
width: "calc(1000px + calc(2 * 24px))",
|
||||||
|
minWidth: "calc(100vh - 273px)",
|
||||||
|
margin: "24px auto"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const GamesContainer = styled("div", {
|
||||||
|
base: {
|
||||||
|
background: theme.color.background.d200,
|
||||||
|
padding: "32px 16px",
|
||||||
|
borderRadius: 5,
|
||||||
|
border: `1px solid ${theme.color.gray.d400}`,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
height: "calc(100vh - 300px)",
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const EmptyState = styled("div", {
|
||||||
|
base: {
|
||||||
|
height: "100%",
|
||||||
|
width: "100%",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.space[8],
|
||||||
|
flexDirection: "column"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const SteamLogoContainer = styled("div", {
|
||||||
|
base: {
|
||||||
|
height: 60,
|
||||||
|
width: 60,
|
||||||
|
padding: 4,
|
||||||
|
borderRadius: 8,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: theme.color.background.d200,
|
||||||
|
border: `1px solid ${theme.color.gray.d400}`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
export function SteamRoute() {
|
||||||
|
const steam = useSteam();
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
// steam.client.loginStream.connect();
|
||||||
|
|
||||||
|
// Clean up on component unmount
|
||||||
|
// onCleanup(() => {
|
||||||
|
// steam.client.loginStream.disconnect();
|
||||||
|
// });
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header />
|
||||||
|
<Root>
|
||||||
|
<Section>
|
||||||
|
<TitleHeader>
|
||||||
|
<TitleWrapper>
|
||||||
|
<TitleContainer>
|
||||||
|
<Title>
|
||||||
|
Steam Library
|
||||||
|
</Title>
|
||||||
|
<Description>
|
||||||
|
{/* Read and write directly to databases and stores from your projects. */}
|
||||||
|
Install games directly from your Steam account to your Nestri Machine
|
||||||
|
</Description>
|
||||||
|
</TitleContainer>
|
||||||
|
<ButtonContainer>
|
||||||
|
<QRButton>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 32 32">
|
||||||
|
<path fill="currentColor" d="M15.974 0C7.573 0 .682 6.479.031 14.714l8.573 3.547a4.5 4.5 0 0 1 2.552-.786c.083 0 .167.005.25.005l3.813-5.521v-.078c0-3.328 2.703-6.031 6.031-6.031s6.036 2.708 6.036 6.036a6.04 6.04 0 0 1-6.036 6.031h-.135l-5.438 3.88c0 .073.005.141.005.214c0 2.5-2.021 4.526-4.521 4.526c-2.177 0-4.021-1.563-4.443-3.635L.583 20.36c1.901 6.719 8.063 11.641 15.391 11.641c8.833 0 15.995-7.161 15.995-16s-7.161-16-15.995-16zm-5.922 24.281l-1.964-.813a3.4 3.4 0 0 0 1.755 1.667a3.404 3.404 0 0 0 4.443-1.833a3.38 3.38 0 0 0 .005-2.599a3.36 3.36 0 0 0-1.839-1.844a3.38 3.38 0 0 0-2.5-.042l2.026.839c1.276.536 1.88 2 1.349 3.276s-2 1.88-3.276 1.349zm15.219-12.406a4.025 4.025 0 0 0-4.016-4.021a4.02 4.02 0 1 0 0 8.042a4.02 4.02 0 0 0 4.016-4.021m-7.026-.005c0-1.672 1.349-3.021 3.016-3.021s3.026 1.349 3.026 3.021c0 1.667-1.359 3.021-3.026 3.021s-3.016-1.354-3.016-3.021" />
|
||||||
|
</svg>
|
||||||
|
<ButtonText>
|
||||||
|
Connect Steam
|
||||||
|
</ButtonText>
|
||||||
|
</QRButton>
|
||||||
|
</ButtonContainer>
|
||||||
|
</TitleWrapper>
|
||||||
|
</TitleHeader>
|
||||||
|
<Body>
|
||||||
|
<GamesContainer>
|
||||||
|
<EmptyState>
|
||||||
|
<SteamLogoContainer>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||||
|
<path fill="currentColor" d="M15.974 0C7.573 0 .682 6.479.031 14.714l8.573 3.547a4.5 4.5 0 0 1 2.552-.786c.083 0 .167.005.25.005l3.813-5.521v-.078c0-3.328 2.703-6.031 6.031-6.031s6.036 2.708 6.036 6.036a6.04 6.04 0 0 1-6.036 6.031h-.135l-5.438 3.88c0 .073.005.141.005.214c0 2.5-2.021 4.526-4.521 4.526c-2.177 0-4.021-1.563-4.443-3.635L.583 20.36c1.901 6.719 8.063 11.641 15.391 11.641c8.833 0 15.995-7.161 15.995-16s-7.161-16-15.995-16zm-5.922 24.281l-1.964-.813a3.4 3.4 0 0 0 1.755 1.667a3.404 3.404 0 0 0 4.443-1.833a3.38 3.38 0 0 0 .005-2.599a3.36 3.36 0 0 0-1.839-1.844a3.38 3.38 0 0 0-2.5-.042l2.026.839c1.276.536 1.88 2 1.349 3.276s-2 1.88-3.276 1.349zm15.219-12.406a4.025 4.025 0 0 0-4.016-4.021a4.02 4.02 0 1 0 0 8.042a4.02 4.02 0 0 0 4.016-4.021m-7.026-.005c0-1.672 1.349-3.021 3.016-3.021s3.026 1.349 3.026 3.021c0 1.667-1.359 3.021-3.026 3.021s-3.016-1.354-3.016-3.021" />
|
||||||
|
</svg>
|
||||||
|
</SteamLogoContainer>
|
||||||
|
<Text align="center" style={{ "letter-spacing": "-0.3px" }} size="base" >
|
||||||
|
{/* After connecting your Steam account, your games will appear here */}
|
||||||
|
{/* URL: {steam.client.loginStream.loginUrl()} */}
|
||||||
|
</Text>
|
||||||
|
</EmptyState>
|
||||||
|
</GamesContainer>
|
||||||
|
</Body>
|
||||||
|
</Section>
|
||||||
|
</Root>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createStore } from "solid-js/store";
|
import { createStore, reconcile } from "solid-js/store";
|
||||||
import { makePersisted } from "@solid-primitives/storage";
|
import { makePersisted } from "@solid-primitives/storage";
|
||||||
import { ParentProps, createContext, useContext } from "solid-js";
|
import { ParentProps, createContext, useContext } from "solid-js";
|
||||||
|
|
||||||
@@ -10,7 +10,6 @@ function init() {
|
|||||||
createStore({
|
createStore({
|
||||||
account: "",
|
account: "",
|
||||||
team: "",
|
team: "",
|
||||||
dummy: "",
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -32,3 +31,73 @@ export function useStorage() {
|
|||||||
}
|
}
|
||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { createEffect } from "solid-js";
|
||||||
|
import { useOpenAuth } from "@openauthjs/solid"
|
||||||
|
import { Team } from "@nestri/core/team/index";
|
||||||
|
import { createInitializedContext } from "../common/context";
|
||||||
|
|
||||||
|
type Storage = {
|
||||||
|
accounts: Record<string, {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
avatarUrl?: string
|
||||||
|
discriminator: number
|
||||||
|
name: string;
|
||||||
|
polarCustomerID: string;
|
||||||
|
teams: Team.Info[];
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const { use: useAccount, provider: AccountProvider } = createInitializedContext("AccountContext", () => {
|
||||||
|
const auth = useOpenAuth()
|
||||||
|
const [store, setStore] = makePersisted(
|
||||||
|
createStore<Storage>({
|
||||||
|
accounts: {},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "nestri.account",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
async function refresh(id: string) {
|
||||||
|
const access = await auth.access(id).catch(() => { })
|
||||||
|
if (!access) {
|
||||||
|
auth.authorize()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return await fetch(import.meta.env.VITE_API_URL + "/account", {
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${access}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(val => val.json())
|
||||||
|
.then(val => setStore("accounts", id, reconcile(val.data)))
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect((previous: string[]) => {
|
||||||
|
if (!Object.values(auth.all).length) {
|
||||||
|
auth.authorize()
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
for (const item of Object.values(auth.all)) {
|
||||||
|
if (previous.includes(item.id)) continue
|
||||||
|
refresh(item.id)
|
||||||
|
}
|
||||||
|
return Object.keys(auth.all)
|
||||||
|
}, [] as string[])
|
||||||
|
|
||||||
|
return {
|
||||||
|
get all() {
|
||||||
|
return store.accounts
|
||||||
|
},
|
||||||
|
get current() {
|
||||||
|
return store.accounts[auth.subject!.id]
|
||||||
|
},
|
||||||
|
refresh,
|
||||||
|
get ready() {
|
||||||
|
if (!auth.subject) return false
|
||||||
|
return store.accounts[auth.subject.id] !== undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
36
packages/www/src/providers/api.tsx
Normal file
36
packages/www/src/providers/api.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { hc } from "hono/client";
|
||||||
|
import { useTeam } from "./context";
|
||||||
|
import { useOpenAuth } from "@openauthjs/solid";
|
||||||
|
import { type app } from "@nestri/functions/api/index";
|
||||||
|
import { createInitializedContext } from "@nestri/www/common/context";
|
||||||
|
|
||||||
|
|
||||||
|
export const { use: useApi, provider: ApiProvider } = createInitializedContext(
|
||||||
|
"Api",
|
||||||
|
() => {
|
||||||
|
const team = useTeam();
|
||||||
|
const auth = useOpenAuth();
|
||||||
|
|
||||||
|
const client = hc<typeof app>(import.meta.env.VITE_API_URL, {
|
||||||
|
async fetch(...args: Parameters<typeof fetch>): Promise<Response> {
|
||||||
|
const [input, init] = args;
|
||||||
|
const request =
|
||||||
|
input instanceof Request ? input : new Request(input, init);
|
||||||
|
const headers = new Headers(request.headers);
|
||||||
|
headers.set("authorization", `Bearer ${await auth.access()}`);
|
||||||
|
headers.set("x-nestri-team", team().id);
|
||||||
|
|
||||||
|
return fetch(
|
||||||
|
new Request(request, {
|
||||||
|
...init,
|
||||||
|
headers,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
client,
|
||||||
|
ready: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
import { type Team } from "@nestri/core/team/index";
|
|
||||||
import { makePersisted } from "@solid-primitives/storage";
|
|
||||||
import { useLocation, useNavigate } from "@solidjs/router";
|
|
||||||
import { createClient } from "@openauthjs/openauth/client";
|
|
||||||
import { createInitializedContext } from "../common/context";
|
|
||||||
import { createEffect, createMemo, onMount } from "solid-js";
|
|
||||||
import { createStore, produce, reconcile } from "solid-js/store";
|
|
||||||
|
|
||||||
interface AccountInfo {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
name: string;
|
|
||||||
access: string;
|
|
||||||
refresh: string;
|
|
||||||
avatarUrl: string;
|
|
||||||
teams: Team.Info[];
|
|
||||||
discriminator: number;
|
|
||||||
polarCustomerID: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Storage {
|
|
||||||
accounts: Record<string, AccountInfo>;
|
|
||||||
current?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
//TODO: Fix bug where authenticator deletes auth state for no reason
|
|
||||||
|
|
||||||
export const client = createClient({
|
|
||||||
issuer: import.meta.env.VITE_AUTH_URL,
|
|
||||||
clientID: "web",
|
|
||||||
});
|
|
||||||
|
|
||||||
export const { use: useAuth, provider: AuthProvider } =
|
|
||||||
createInitializedContext("AuthContext", () => {
|
|
||||||
const [store, setStore] = makePersisted(
|
|
||||||
createStore<Storage>({
|
|
||||||
accounts: {},
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
name: "radiant.auth",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const location = useLocation();
|
|
||||||
const params = createMemo(
|
|
||||||
() => new URLSearchParams(location.hash.substring(1)),
|
|
||||||
);
|
|
||||||
const accessToken = createMemo(() => params().get("access_token"));
|
|
||||||
const refreshToken = createMemo(() => params().get("refresh_token"));
|
|
||||||
|
|
||||||
|
|
||||||
createEffect(async () => {
|
|
||||||
// if (!result.current && Object.keys(store.accounts).length) {
|
|
||||||
// result.switch(Object.keys(store.accounts)[0])
|
|
||||||
// navigate("/")
|
|
||||||
// }
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(async () => {
|
|
||||||
if (accessToken()) return;
|
|
||||||
if (Object.keys(store.accounts).length) return;
|
|
||||||
const redirect = await client.authorize(window.location.origin, "token");
|
|
||||||
window.location.href = redirect.url
|
|
||||||
});
|
|
||||||
|
|
||||||
createEffect(async () => {
|
|
||||||
const current = store.current;
|
|
||||||
const accounts = store.accounts;
|
|
||||||
if (!current) return;
|
|
||||||
const match = accounts[current];
|
|
||||||
if (match) return;
|
|
||||||
const keys = Object.keys(accounts);
|
|
||||||
if (keys.length) {
|
|
||||||
setStore("current", keys[0]);
|
|
||||||
navigate("/");
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const redirect = await client.authorize(window.location.origin, "token");
|
|
||||||
window.location.href = redirect.url
|
|
||||||
});
|
|
||||||
|
|
||||||
async function refresh() {
|
|
||||||
for (const account of [...Object.values(store.accounts)]) {
|
|
||||||
if (!account.refresh) continue;
|
|
||||||
const result = await client.refresh(account.refresh, {
|
|
||||||
access: account.access,
|
|
||||||
})
|
|
||||||
if (result.err) {
|
|
||||||
console.log("error", result.err)
|
|
||||||
if ("id" in account)
|
|
||||||
setStore(produce((state) => {
|
|
||||||
delete state.accounts[account.id];
|
|
||||||
}))
|
|
||||||
continue
|
|
||||||
};
|
|
||||||
const tokens = result.tokens || {
|
|
||||||
access: account.access,
|
|
||||||
refresh: account.refresh,
|
|
||||||
}
|
|
||||||
fetch(import.meta.env.VITE_API_URL + "/account", {
|
|
||||||
headers: {
|
|
||||||
authorization: `Bearer ${tokens.access}`,
|
|
||||||
},
|
|
||||||
}).then(async (response) => {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10000));
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const result = await response.json();
|
|
||||||
const info = await result.data;
|
|
||||||
|
|
||||||
setStore(
|
|
||||||
"accounts",
|
|
||||||
info.id,
|
|
||||||
reconcile({
|
|
||||||
...info,
|
|
||||||
...tokens,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok)
|
|
||||||
console.log("error from account", response.json())
|
|
||||||
setStore(
|
|
||||||
produce((state) => {
|
|
||||||
delete state.accounts[account.id];
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
if (refreshToken() && accessToken()) {
|
|
||||||
const result = await fetch(import.meta.env.VITE_API_URL + "/account", {
|
|
||||||
headers: {
|
|
||||||
authorization: `Bearer ${accessToken()}`,
|
|
||||||
},
|
|
||||||
}).catch(() => { })
|
|
||||||
if (result?.ok) {
|
|
||||||
const response = await result.json();
|
|
||||||
const info = await response.data;
|
|
||||||
setStore(
|
|
||||||
"accounts",
|
|
||||||
info.id,
|
|
||||||
reconcile({
|
|
||||||
...info,
|
|
||||||
access: accessToken(),
|
|
||||||
refresh: refreshToken(),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
setStore("current", info.id);
|
|
||||||
}
|
|
||||||
window.location.hash = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
await refresh();
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
// const bar = useCommandBar()
|
|
||||||
|
|
||||||
// bar.register("auth", async () => {
|
|
||||||
// return [
|
|
||||||
// {
|
|
||||||
// category: "Account",
|
|
||||||
// title: "Logout",
|
|
||||||
// icon: IconLogout,
|
|
||||||
// run: async (bar) => {
|
|
||||||
// result.logout();
|
|
||||||
// setStore("current", undefined);
|
|
||||||
// navigate("/");
|
|
||||||
// bar.hide()
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// category: "Add Account",
|
|
||||||
// title: "Add Account",
|
|
||||||
// icon: IconUserAdd,
|
|
||||||
// run: async () => {
|
|
||||||
// const redir = await client.authorize(window.location.origin, "token");
|
|
||||||
// window.location.href = redir.url
|
|
||||||
// bar.hide()
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// ...result.all()
|
|
||||||
// .filter((item) => item.id !== result.current.id)
|
|
||||||
// .map((item) => ({
|
|
||||||
// category: "Account",
|
|
||||||
// title: "Switch to " + item.email,
|
|
||||||
// icon: IconUser,
|
|
||||||
// run: async () => {
|
|
||||||
// result.switch(item.id);
|
|
||||||
// navigate("/");
|
|
||||||
// bar.hide()
|
|
||||||
// },
|
|
||||||
// })),
|
|
||||||
// ]
|
|
||||||
// })
|
|
||||||
|
|
||||||
const result = {
|
|
||||||
get current() {
|
|
||||||
return store.accounts[store.current!]!;
|
|
||||||
},
|
|
||||||
switch(accountID: string) {
|
|
||||||
setStore("current", accountID);
|
|
||||||
},
|
|
||||||
all() {
|
|
||||||
return Object.values(store.accounts);
|
|
||||||
},
|
|
||||||
refresh,
|
|
||||||
logout() {
|
|
||||||
setStore(
|
|
||||||
produce((state) => {
|
|
||||||
if (!state.current) return;
|
|
||||||
delete state.accounts[state.current];
|
|
||||||
state.current = Object.keys(state.accounts)[0];
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
get ready() {
|
|
||||||
return Boolean(!accessToken() && store.current);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return result;
|
|
||||||
});
|
|
||||||
10
packages/www/src/providers/context.tsx
Normal file
10
packages/www/src/providers/context.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Team } from "@nestri/core/team/index";
|
||||||
|
import { Accessor, createContext, useContext } from "solid-js";
|
||||||
|
|
||||||
|
export const TeamContext = createContext<Accessor<Team.Info>>();
|
||||||
|
|
||||||
|
export function useTeam() {
|
||||||
|
const context = useContext(TeamContext);
|
||||||
|
if (!context) throw new Error("No team context");
|
||||||
|
return context;
|
||||||
|
}
|
||||||
223
packages/www/src/providers/steam.tsx
Normal file
223
packages/www/src/providers/steam.tsx
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import { useTeam } from "./context";
|
||||||
|
import { EventSource } from 'eventsource'
|
||||||
|
import { useOpenAuth } from "@openauthjs/solid";
|
||||||
|
import { createSignal, onCleanup } from "solid-js";
|
||||||
|
import { createInitializedContext } from "../common/context";
|
||||||
|
|
||||||
|
// Type definitions for the events
|
||||||
|
interface SteamEventTypes {
|
||||||
|
'url': string;
|
||||||
|
'login-attempt': { username: string };
|
||||||
|
'login-success': { username: string; steamId: string };
|
||||||
|
'login-unsuccessful': { error: string };
|
||||||
|
'logged-off': { reason: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type for the connection
|
||||||
|
type SteamConnection = {
|
||||||
|
addEventListener: <T extends keyof SteamEventTypes>(
|
||||||
|
event: T,
|
||||||
|
callback: (data: SteamEventTypes[T]) => void
|
||||||
|
) => () => void;
|
||||||
|
removeEventListener: <T extends keyof SteamEventTypes>(
|
||||||
|
event: T,
|
||||||
|
callback: (data: SteamEventTypes[T]) => void
|
||||||
|
) => void;
|
||||||
|
disconnect: () => void;
|
||||||
|
isConnected: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SteamContext {
|
||||||
|
ready: boolean;
|
||||||
|
client: {
|
||||||
|
// Regular API endpoints
|
||||||
|
whoami: () => Promise<any>;
|
||||||
|
games: () => Promise<any>;
|
||||||
|
// SSE connection for login
|
||||||
|
login: {
|
||||||
|
connect: () => SteamConnection;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the initialized context
|
||||||
|
export const { use: useSteam, provider: SteamProvider } = createInitializedContext(
|
||||||
|
"Steam",
|
||||||
|
() => {
|
||||||
|
const team = useTeam();
|
||||||
|
const auth = useOpenAuth();
|
||||||
|
|
||||||
|
// Create the HTTP client for regular endpoints
|
||||||
|
const client = {
|
||||||
|
// Regular HTTP endpoints
|
||||||
|
whoami: async () => {
|
||||||
|
const token = await auth.access();
|
||||||
|
const response = await fetch(`${import.meta.env.VITE_STEAM_URL}/whoami`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'x-nestri-team': team().id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
games: async () => {
|
||||||
|
const token = await auth.access();
|
||||||
|
const response = await fetch(`${import.meta.env.VITE_STEAM_URL}/games`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'x-nestri-team': team().id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
// SSE connection factory for login
|
||||||
|
login: {
|
||||||
|
connect: async (): Promise<SteamConnection> => {
|
||||||
|
let eventSource: EventSource | null = null;
|
||||||
|
const [isConnected, setIsConnected] = createSignal(false);
|
||||||
|
|
||||||
|
// Store event listeners
|
||||||
|
const listeners: Record<string, Array<(data: any) => void>> = {
|
||||||
|
'url': [],
|
||||||
|
'login-attempt': [],
|
||||||
|
'login-success': [],
|
||||||
|
'login-unsuccessful': [],
|
||||||
|
'logged-off': []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Method to add event listeners
|
||||||
|
const addEventListener = <T extends keyof SteamEventTypes>(
|
||||||
|
event: T,
|
||||||
|
callback: (data: SteamEventTypes[T]) => void
|
||||||
|
) => {
|
||||||
|
if (!listeners[event]) {
|
||||||
|
listeners[event] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
listeners[event].push(callback as any);
|
||||||
|
|
||||||
|
// Return a function to remove this specific listener
|
||||||
|
return () => {
|
||||||
|
removeEventListener(event, callback);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Method to remove event listeners
|
||||||
|
const removeEventListener = <T extends keyof SteamEventTypes>(
|
||||||
|
event: T,
|
||||||
|
callback: (data: SteamEventTypes[T]) => void
|
||||||
|
) => {
|
||||||
|
if (listeners[event]) {
|
||||||
|
const index = listeners[event].indexOf(callback as any);
|
||||||
|
if (index !== -1) {
|
||||||
|
listeners[event].splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize connection
|
||||||
|
const initConnection = async () => {
|
||||||
|
if (eventSource) {
|
||||||
|
eventSource.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = await auth.access();
|
||||||
|
|
||||||
|
eventSource = new EventSource(`${import.meta.env.VITE_STEAM_URL}/login`, {
|
||||||
|
fetch: (input, init) =>
|
||||||
|
fetch(input, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
...init?.headers,
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'x-nestri-team': team().id
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
eventSource.onopen = () => {
|
||||||
|
console.log('Connected to Steam login stream');
|
||||||
|
setIsConnected(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set up event handlers for all specific events
|
||||||
|
['url', 'login-attempt', 'login-success', 'login-unsuccessful', 'logged-off'].forEach((eventType) => {
|
||||||
|
eventSource!.addEventListener(eventType, (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
console.log(`Received ${eventType} event:`, data);
|
||||||
|
|
||||||
|
// Notify all registered listeners for this event type
|
||||||
|
if (listeners[eventType]) {
|
||||||
|
listeners[eventType].forEach(callback => {
|
||||||
|
callback(data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error parsing ${eventType} event data:`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle generic messages (fallback)
|
||||||
|
eventSource.onmessage = (event) => {
|
||||||
|
console.log('Received generic message:', event.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onerror = (error) => {
|
||||||
|
console.error('Steam login stream error:', error);
|
||||||
|
setIsConnected(false);
|
||||||
|
// Attempt to reconnect after a delay
|
||||||
|
setTimeout(initConnection, 5000);
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to connect to Steam login stream:', error);
|
||||||
|
setIsConnected(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Disconnection function
|
||||||
|
const disconnect = () => {
|
||||||
|
if (eventSource) {
|
||||||
|
eventSource.close();
|
||||||
|
eventSource = null;
|
||||||
|
setIsConnected(false);
|
||||||
|
console.log('Disconnected from Steam login stream');
|
||||||
|
|
||||||
|
// Clear all listeners
|
||||||
|
Object.keys(listeners).forEach(key => {
|
||||||
|
listeners[key] = [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start the connection immediately
|
||||||
|
await initConnection();
|
||||||
|
|
||||||
|
// Create the connection interface
|
||||||
|
const connection: SteamConnection = {
|
||||||
|
addEventListener,
|
||||||
|
removeEventListener,
|
||||||
|
disconnect,
|
||||||
|
isConnected: () => isConnected()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clean up on context destruction
|
||||||
|
onCleanup(() => {
|
||||||
|
disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
client,
|
||||||
|
ready: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
39
packages/www/src/providers/zero.tsx
Normal file
39
packages/www/src/providers/zero.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { useTeam } from "./context"
|
||||||
|
import { createEffect } from "solid-js"
|
||||||
|
import { schema } from "@nestri/zero/schema"
|
||||||
|
import { useQuery } from "@rocicorp/zero/solid"
|
||||||
|
import { useOpenAuth } from "@openauthjs/solid"
|
||||||
|
import { Query, Schema, Zero } from "@rocicorp/zero"
|
||||||
|
import { useAccount } from "@nestri/www/providers/account"
|
||||||
|
import { createInitializedContext } from "@nestri/www/common/context"
|
||||||
|
|
||||||
|
export const { use: useZero, provider: ZeroProvider } =
|
||||||
|
createInitializedContext("ZeroContext", () => {
|
||||||
|
const auth = useOpenAuth()
|
||||||
|
const account = useAccount()
|
||||||
|
const team = useTeam()
|
||||||
|
const zero = new Zero({
|
||||||
|
schema: schema,
|
||||||
|
auth: () => auth.access(),
|
||||||
|
userID: account.current.email,
|
||||||
|
storageKey: team().id,
|
||||||
|
server: import.meta.env.VITE_ZERO_URL,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
mutate: zero.mutate,
|
||||||
|
query: zero.query,
|
||||||
|
client: zero,
|
||||||
|
ready: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export function usePersistentQuery<TSchema extends Schema, TTable extends keyof TSchema['tables'] & string, TReturn>(querySignal: () => Query<TSchema, TTable, TReturn>) {
|
||||||
|
const team = useTeam()
|
||||||
|
//@ts-ignore
|
||||||
|
const q = () => querySignal().where("team_id", "=", team().id).where("time_deleted", "IS", null)
|
||||||
|
createEffect(() => {
|
||||||
|
q().preload()
|
||||||
|
})
|
||||||
|
return useQuery<TSchema, TTable, TReturn>(q)
|
||||||
|
}
|
||||||
4
packages/www/src/sst-env.d.ts
vendored
4
packages/www/src/sst-env.d.ts
vendored
@@ -4,8 +4,10 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_API_URL: string
|
readonly VITE_API_URL: string
|
||||||
readonly VITE_AUTH_URL: string
|
|
||||||
readonly VITE_STAGE: string
|
readonly VITE_STAGE: string
|
||||||
|
readonly VITE_AUTH_URL: string
|
||||||
|
readonly VITE_ZERO_URL: string
|
||||||
|
readonly VITE_STEAM_URL: string
|
||||||
}
|
}
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
readonly env: ImportMetaEnv
|
readonly env: ImportMetaEnv
|
||||||
|
|||||||
97
packages/www/src/ui/avatar.tsx
Normal file
97
packages/www/src/ui/avatar.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
const DEFAULT_COLORS = ['#6A5ACD', '#E63525', '#20B2AA', '#E87D58'];
|
||||||
|
|
||||||
|
const getModulo = (value: number, divisor: number, useEvenCheck?: number) => {
|
||||||
|
const remainder = value % divisor;
|
||||||
|
if (useEvenCheck && Math.floor(value / Math.pow(10, useEvenCheck) % 10) % 2 === 0) {
|
||||||
|
return -remainder;
|
||||||
|
}
|
||||||
|
return remainder;
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateColors = (name: string, colors = DEFAULT_COLORS) => {
|
||||||
|
const hashCode = name.split('').reduce((acc, char) => {
|
||||||
|
acc = ((acc << 5) - acc) + char.charCodeAt(0);
|
||||||
|
return acc & acc;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const hash = Math.abs(hashCode);
|
||||||
|
const numColors = colors.length;
|
||||||
|
|
||||||
|
return Array.from({ length: 3 }, (_, index) => ({
|
||||||
|
color: colors[(hash + index) % numColors],
|
||||||
|
translateX: getModulo(hash * (index + 1), 4, 1),
|
||||||
|
translateY: getModulo(hash * (index + 1), 4, 2),
|
||||||
|
scale: 1.2 + getModulo(hash * (index + 1), 2) / 10,
|
||||||
|
rotate: getModulo(hash * (index + 1), 360, 1)
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
type Props = {
|
||||||
|
name: string;
|
||||||
|
size?: number;
|
||||||
|
class?: string;
|
||||||
|
colors?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Avatar({ class: className, name, size = 80, colors = DEFAULT_COLORS }: Props) {
|
||||||
|
const colorData = generateColors(name, colors);
|
||||||
|
|
||||||
|
const blurValue = Math.max(1, Math.min(7, size / 10));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 80 80"
|
||||||
|
fill="none"
|
||||||
|
role="img"
|
||||||
|
class={className}
|
||||||
|
preserveAspectRatio="xMidYMid meet"
|
||||||
|
aria-describedby={name}
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
>
|
||||||
|
<title id={name}>{`Fallback avatar for ${name}`}</title>
|
||||||
|
<mask
|
||||||
|
id="mask__marble"
|
||||||
|
maskUnits="userSpaceOnUse"
|
||||||
|
x={0}
|
||||||
|
y={0}
|
||||||
|
width={80}
|
||||||
|
height={80}
|
||||||
|
>
|
||||||
|
<rect width={80} height={80} rx={160} fill="#FFFFFF" />
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask__marble)">
|
||||||
|
<rect width={80} height={80} rx={160} fill={colorData[0].color} />
|
||||||
|
<path
|
||||||
|
filter="url(#prefix__filter0_f)"
|
||||||
|
d="M32.414 59.35L50.376 70.5H72.5v-71H33.728L26.5 13.381l19.057 27.08L32.414 59.35z"
|
||||||
|
fill={colorData[1].color}
|
||||||
|
transform={
|
||||||
|
`translate(${colorData[1].translateX} ${colorData[1].translateY})
|
||||||
|
rotate(${colorData[1].rotate} 40 40)
|
||||||
|
scale(${colorData[1].scale})`}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
filter="url(#prefix__filter0_f)"
|
||||||
|
style={{ "mix-blend-mode": "overlay" }}
|
||||||
|
d="M22.216 24L0 46.75l14.108 38.129L78 86l-3.081-59.276-22.378 4.005 12.972 20.186-23.35 27.395L22.215 24z"
|
||||||
|
fill={colorData[2].color}
|
||||||
|
transform={
|
||||||
|
`translate(${colorData[2].translateX} ${colorData[2].translateY})
|
||||||
|
rotate(${colorData[2].rotate} 40 40)
|
||||||
|
scale(${colorData[2].scale})`}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<filter
|
||||||
|
id="prefix__filter0_f"
|
||||||
|
filterUnits="userSpaceOnUse"
|
||||||
|
color-interpolation-filters="s-rGB"
|
||||||
|
>
|
||||||
|
<feFlood flood-opacity={0} result="BackgroundImageFix" />
|
||||||
|
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||||
|
<feGaussianBlur stdDeviation={blurValue} result="effect1_foregroundBlur" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ export const Button = styled("button", {
|
|||||||
lineHeight: "normal",
|
lineHeight: "normal",
|
||||||
fontFamily: theme.font.family.heading,
|
fontFamily: theme.font.family.heading,
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
|
cursor: "pointer",
|
||||||
transitionDelay: "0s, 0s",
|
transitionDelay: "0s, 0s",
|
||||||
transitionDuration: "0.2s, 0.2s",
|
transitionDuration: "0.2s, 0.2s",
|
||||||
transitionProperty: "background-color, border",
|
transitionProperty: "background-color, border",
|
||||||
|
|||||||
160
packages/www/src/ui/custom-qr.tsx
Normal file
160
packages/www/src/ui/custom-qr.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import QRCodeUtil from 'qrcode';
|
||||||
|
import { createMemo, JSXElement } from "solid-js"
|
||||||
|
|
||||||
|
const generateMatrix = (
|
||||||
|
value: string,
|
||||||
|
errorCorrectionLevel: QRCodeUtil.QRCodeErrorCorrectionLevel
|
||||||
|
) => {
|
||||||
|
const arr = Array.prototype.slice.call(
|
||||||
|
QRCodeUtil.create(value, { errorCorrectionLevel }).modules.data,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const sqrt = Math.sqrt(arr.length);
|
||||||
|
return arr.reduce(
|
||||||
|
(rows, key, index) =>
|
||||||
|
(index % sqrt === 0
|
||||||
|
? rows.push([key])
|
||||||
|
: rows[rows.length - 1].push(key)) && rows,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
ecl?: QRCodeUtil.QRCodeErrorCorrectionLevel;
|
||||||
|
size?: number;
|
||||||
|
uri: string;
|
||||||
|
clearArea?: boolean;
|
||||||
|
image?: HTMLImageElement;
|
||||||
|
imageBackground?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function QRCode({
|
||||||
|
ecl = 'M',
|
||||||
|
size: sizeProp = 200,
|
||||||
|
uri,
|
||||||
|
clearArea = false,
|
||||||
|
image,
|
||||||
|
imageBackground = 'transparent',
|
||||||
|
}: Props) {
|
||||||
|
const logoSize = clearArea ? 32 : 0;
|
||||||
|
const size = sizeProp - 10 * 2;
|
||||||
|
|
||||||
|
const dots = createMemo(() => {
|
||||||
|
const dots: JSXElement[] = [];
|
||||||
|
const matrix = generateMatrix(uri, ecl);
|
||||||
|
const cellSize = size / matrix.length;
|
||||||
|
let qrList = [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 1, y: 0 },
|
||||||
|
{ x: 0, y: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
qrList.forEach(({ x, y }) => {
|
||||||
|
const x1 = (matrix.length - 7) * cellSize * x;
|
||||||
|
const y1 = (matrix.length - 7) * cellSize * y;
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
dots.push(
|
||||||
|
<rect
|
||||||
|
id={`${i}-${x}-${y}`}
|
||||||
|
fill={
|
||||||
|
i % 2 !== 0
|
||||||
|
? 'var(--nestri-qr-background, var(--nestri-body-background))'
|
||||||
|
: 'var(--nestri-qr-dot-color)'
|
||||||
|
}
|
||||||
|
rx={(i - 2) * -5 + (i === 0 ? 2 : 3)}
|
||||||
|
ry={(i - 2) * -5 + (i === 0 ? 2 : 3)}
|
||||||
|
width={cellSize * (7 - i * 2)}
|
||||||
|
height={cellSize * (7 - i * 2)}
|
||||||
|
x={x1 + cellSize * i}
|
||||||
|
y={y1 + cellSize * i}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (image) {
|
||||||
|
const x1 = (matrix.length - 7) * cellSize * 1;
|
||||||
|
const y1 = (matrix.length - 7) * cellSize * 1;
|
||||||
|
dots.push(
|
||||||
|
<>
|
||||||
|
<rect
|
||||||
|
fill={imageBackground}
|
||||||
|
rx={(0 - 2) * -5 + 2}
|
||||||
|
ry={(0 - 2) * -5 + 2}
|
||||||
|
width={cellSize * (7 - 0 * 2)}
|
||||||
|
height={cellSize * (7 - 0 * 2)}
|
||||||
|
x={x1 + cellSize * 0}
|
||||||
|
y={y1 + cellSize * 0}
|
||||||
|
/>
|
||||||
|
<foreignObject
|
||||||
|
width={cellSize * (7 - 0 * 2)}
|
||||||
|
height={cellSize * (7 - 0 * 2)}
|
||||||
|
x={x1 + cellSize * 0}
|
||||||
|
y={y1 + cellSize * 0}
|
||||||
|
>
|
||||||
|
<div style={{ "border-radius": `${(0 - 2) * -5 + 2}px`, overflow: 'hidden' }}>
|
||||||
|
{image}
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearArenaSize = Math.floor((logoSize + 25) / cellSize);
|
||||||
|
const matrixMiddleStart = matrix.length / 2 - clearArenaSize / 2;
|
||||||
|
const matrixMiddleEnd = matrix.length / 2 + clearArenaSize / 2 - 1;
|
||||||
|
|
||||||
|
matrix.forEach((row: QRCodeUtil.QRCode[], i: number) => {
|
||||||
|
row.forEach((_: any, j: number) => {
|
||||||
|
if (matrix[i][j]) {
|
||||||
|
// Do not render dots under position squares
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
(i < 7 && j < 7) ||
|
||||||
|
(i > matrix.length - 8 && j < 7) ||
|
||||||
|
(i < 7 && j > matrix.length - 8)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
//if (image && i > matrix.length - 9 && j > matrix.length - 9) return;
|
||||||
|
if (
|
||||||
|
image ||
|
||||||
|
!(
|
||||||
|
i > matrixMiddleStart &&
|
||||||
|
i < matrixMiddleEnd &&
|
||||||
|
j > matrixMiddleStart &&
|
||||||
|
j < matrixMiddleEnd
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
dots.push(
|
||||||
|
<circle
|
||||||
|
id={`circle-${i}-${j}`}
|
||||||
|
cx={i * cellSize + cellSize / 2}
|
||||||
|
cy={j * cellSize + cellSize / 2}
|
||||||
|
fill="var(--nestri-qr-dot-color)"
|
||||||
|
r={cellSize / 3}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return dots;
|
||||||
|
}, [ecl, size, uri]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
height={size}
|
||||||
|
width={size}
|
||||||
|
viewBox={`0 0 ${size} ${size}`}
|
||||||
|
style={{
|
||||||
|
width: `${size}px`,
|
||||||
|
height: `${size}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<rect fill="transparent" height={size} width={size} />
|
||||||
|
{dots()}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
import { theme } from "./theme";
|
import { theme } from "./theme";
|
||||||
|
import { utility } from "./utility";
|
||||||
|
import { Container } from "./layout";
|
||||||
import { styled } from "@macaron-css/solid"
|
import { styled } from "@macaron-css/solid"
|
||||||
import { CSSProperties } from "@macaron-css/core";
|
import { CSSProperties } from "@macaron-css/core";
|
||||||
import { ComponentProps, createMemo, For, JSX, Show, splitProps } from "solid-js";
|
import { ComponentProps, For, JSX, Show, splitProps } from "solid-js";
|
||||||
import { Container } from "./layout";
|
|
||||||
import { utility } from "./utility";
|
|
||||||
|
|
||||||
// FIXME: Make sure the focus ring goes to red when the input is invalid
|
// FIXME: Make sure the focus ring goes to red when the input is invalid
|
||||||
|
|
||||||
export const inputStyles: CSSProperties = {
|
export const inputStyles: CSSProperties = {
|
||||||
lineHeight: theme.font.lineHeight,
|
lineHeight: theme.font.lineHeight,
|
||||||
appearance: "none",
|
appearance: "none",
|
||||||
|
width: "100%",
|
||||||
fontSize: theme.font.size.sm,
|
fontSize: theme.font.size.sm,
|
||||||
borderRadius: theme.borderRadius,
|
borderRadius: theme.borderRadius,
|
||||||
padding: `0 ${theme.space[3]}`,
|
padding: `0 ${theme.space[3]}`,
|
||||||
@@ -57,12 +58,7 @@ export const Root = styled("label", {
|
|||||||
color: theme.color.gray.d900
|
color: theme.color.gray.d900
|
||||||
},
|
},
|
||||||
danger: {
|
danger: {
|
||||||
color: theme.color.red.d900,
|
color: theme.color.gray.d900,
|
||||||
// selectors: {
|
|
||||||
// "&:has(input)": {
|
|
||||||
// ...inputDangerFocusStyles
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -88,6 +84,12 @@ export const Input = styled("input", {
|
|||||||
"::placeholder": {
|
"::placeholder": {
|
||||||
color: theme.color.gray.d800
|
color: theme.color.gray.d800
|
||||||
},
|
},
|
||||||
|
selectors: {
|
||||||
|
"[data-type='url'] &": {
|
||||||
|
borderTopLeftRadius: 0,
|
||||||
|
borderBottomLeftRadius: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
// ":invalid":{
|
// ":invalid":{
|
||||||
// ...inputDangerFocusStyles
|
// ...inputDangerFocusStyles
|
||||||
// },
|
// },
|
||||||
@@ -121,11 +123,8 @@ export const Input = styled("input", {
|
|||||||
export const InputRadio = styled("input", {
|
export const InputRadio = styled("input", {
|
||||||
base: {
|
base: {
|
||||||
padding: 0,
|
padding: 0,
|
||||||
// borderRadius: 0,
|
|
||||||
WebkitAppearance: "none",
|
WebkitAppearance: "none",
|
||||||
appearance: "none",
|
appearance: "none",
|
||||||
/* For iOS < 15 to remove gradient background */
|
|
||||||
backgroundColor: theme.color.background.d100,
|
|
||||||
/* Not removed via appearance */
|
/* Not removed via appearance */
|
||||||
margin: 0,
|
margin: 0,
|
||||||
font: "inherit",
|
font: "inherit",
|
||||||
@@ -179,6 +178,7 @@ const InputLabel = styled("label", {
|
|||||||
borderColor: theme.color.gray.d400,
|
borderColor: theme.color.gray.d400,
|
||||||
color: theme.color.gray.d800,
|
color: theme.color.gray.d800,
|
||||||
backgroundColor: theme.color.background.d100,
|
backgroundColor: theme.color.background.d100,
|
||||||
|
transition: "all 0.3s cubic-bezier(0.4,0,0.2,1)",
|
||||||
position: "relative",
|
position: "relative",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -197,7 +197,8 @@ const InputLabel = styled("label", {
|
|||||||
borderBottomLeftRadius: theme.borderRadius,
|
borderBottomLeftRadius: theme.borderRadius,
|
||||||
},
|
},
|
||||||
":hover": {
|
":hover": {
|
||||||
backgroundColor: theme.color.background.d200,
|
backgroundColor: theme.color.grayAlpha.d200,
|
||||||
|
color: theme.color.d1000.gray
|
||||||
},
|
},
|
||||||
selectors: {
|
selectors: {
|
||||||
"&:has(input:checked)": {
|
"&:has(input:checked)": {
|
||||||
@@ -243,9 +244,9 @@ export function FormField(props: FormFieldProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SelectProps = {
|
type SelectProps = {
|
||||||
ref: (element: HTMLInputElement) => void;
|
|
||||||
name: string;
|
name: string;
|
||||||
value: any;
|
value: any;
|
||||||
|
ref: (element: HTMLInputElement) => void;
|
||||||
onInput: JSX.EventHandler<HTMLInputElement, InputEvent>;
|
onInput: JSX.EventHandler<HTMLInputElement, InputEvent>;
|
||||||
onChange: JSX.EventHandler<HTMLInputElement, Event>;
|
onChange: JSX.EventHandler<HTMLInputElement, Event>;
|
||||||
onBlur: JSX.EventHandler<HTMLInputElement, FocusEvent>;
|
onBlur: JSX.EventHandler<HTMLInputElement, FocusEvent>;
|
||||||
@@ -276,7 +277,6 @@ const Badge = styled("div", {
|
|||||||
padding: "0 6px",
|
padding: "0 6px",
|
||||||
fontSize: theme.font.size.xs
|
fontSize: theme.font.size.xs
|
||||||
}
|
}
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export function Select(props: SelectProps) {
|
export function Select(props: SelectProps) {
|
||||||
|
|||||||
@@ -3,18 +3,41 @@ import { styled } from "@macaron-css/solid";
|
|||||||
|
|
||||||
export const FullScreen = styled("div", {
|
export const FullScreen = styled("div", {
|
||||||
base: {
|
base: {
|
||||||
inset: 0,
|
|
||||||
display: "flex",
|
display: "flex",
|
||||||
position: "fixed",
|
flexDirection: "column",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
textAlign: "center",
|
||||||
backgroundColor: theme.color.background.d200,
|
width: "100%",
|
||||||
|
justifyContent: "center"
|
||||||
},
|
},
|
||||||
variants: {
|
variants: {
|
||||||
inset: {
|
inset: {
|
||||||
none: {},
|
none: {},
|
||||||
header: {
|
header: {
|
||||||
top: theme.headerHeight.root,
|
paddingTop: `calc(1px + ${theme.headerHeight.root})`,
|
||||||
|
minHeight: `calc(100dvh - ${theme.headerHeight.root})`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const Screen = styled("div", {
|
||||||
|
base: {
|
||||||
|
display: "flex",
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
textAlign: "center",
|
||||||
|
width: "100%",
|
||||||
|
justifyContent: "center"
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
inset: {
|
||||||
|
none: {},
|
||||||
|
header: {
|
||||||
|
paddingTop: `calc(1px + ${theme.headerHeight.root})`,
|
||||||
|
minHeight: `calc(100dvh - ${theme.headerHeight.root})`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
4
packages/www/src/ui/modal/index.ts
Normal file
4
packages/www/src/ui/modal/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { HModalRoot as Root } from './modal-root';
|
||||||
|
export { HModalPanel as Panel } from './modal-panel';
|
||||||
|
export { HModalTrigger as Trigger } from './modal-trigger';
|
||||||
|
export * as Modal from "."
|
||||||
20
packages/www/src/ui/modal/modal-context.tsx
Normal file
20
packages/www/src/ui/modal/modal-context.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Accessor, createContext, Setter, useContext } from "solid-js";
|
||||||
|
|
||||||
|
export const ModalContext = createContext<ModalContext>();
|
||||||
|
|
||||||
|
export function useModal() {
|
||||||
|
const ctx = useContext(ModalContext);
|
||||||
|
if (!ctx) throw new Error("No modal context");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ModalContext = {
|
||||||
|
// core state
|
||||||
|
localId: string;
|
||||||
|
show: Accessor<boolean>;
|
||||||
|
setShow: Setter<boolean>;
|
||||||
|
onShow?: () => void;
|
||||||
|
onClose?: () => void;
|
||||||
|
closeOnBackdropClick?: boolean;
|
||||||
|
alert?: boolean;
|
||||||
|
};
|
||||||
117
packages/www/src/ui/modal/modal-panel.tsx
Normal file
117
packages/www/src/ui/modal/modal-panel.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { useModal } from './use-modal';
|
||||||
|
import { useModal as useModalContext } from './modal-context';
|
||||||
|
import { Accessor, ComponentProps, createEffect, createSignal, onCleanup } from 'solid-js';
|
||||||
|
|
||||||
|
export type ModalProps = Omit<ComponentProps<'dialog'>, 'open'> & {
|
||||||
|
onShow?: () => void;
|
||||||
|
onClose?: () => void;
|
||||||
|
onKeyDown?: () => void;
|
||||||
|
'bind:show': Accessor<boolean>;
|
||||||
|
closeOnBackdropClick?: boolean;
|
||||||
|
alert?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HModalPanel = (props: ComponentProps<'dialog'>) => {
|
||||||
|
const {
|
||||||
|
activateFocusTrap,
|
||||||
|
closeModal,
|
||||||
|
deactivateFocusTrap,
|
||||||
|
showModal,
|
||||||
|
trapFocus,
|
||||||
|
wasModalBackdropClicked,
|
||||||
|
} = useModal();
|
||||||
|
const context = useModalContext();
|
||||||
|
|
||||||
|
const [panelRef, setPanelRef] = createSignal<HTMLDialogElement>();
|
||||||
|
let focusTrapRef: any = null;
|
||||||
|
|
||||||
|
createEffect(async () => {
|
||||||
|
const dialog = panelRef();
|
||||||
|
if (!dialog) return;
|
||||||
|
|
||||||
|
if (context.show()) {
|
||||||
|
// Handle iOS scroll position issue
|
||||||
|
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
|
||||||
|
let originalRAF;
|
||||||
|
|
||||||
|
if (isIOS) {
|
||||||
|
originalRAF = window.requestAnimationFrame;
|
||||||
|
window.requestAnimationFrame = () => 42;
|
||||||
|
}
|
||||||
|
|
||||||
|
await showModal(dialog);
|
||||||
|
|
||||||
|
if (isIOS && originalRAF) {
|
||||||
|
window.requestAnimationFrame = originalRAF;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup focus trap after showing modal
|
||||||
|
focusTrapRef = await trapFocus(dialog);
|
||||||
|
activateFocusTrap(focusTrapRef);
|
||||||
|
|
||||||
|
// Trigger show callback
|
||||||
|
context.onShow?.();
|
||||||
|
} else {
|
||||||
|
await closeModal(dialog);
|
||||||
|
// Trigger close callback
|
||||||
|
context.onClose?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (focusTrapRef) {
|
||||||
|
deactivateFocusTrap(focusTrapRef);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleBackdropClick = async (e: MouseEvent) => {
|
||||||
|
if (context.alert === true || context.closeOnBackdropClick === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only close if the backdrop itself was clicked (not content)
|
||||||
|
if (e.target instanceof HTMLDialogElement && await wasModalBackdropClicked(panelRef(), e)) {
|
||||||
|
context.setShow(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
// Prevent spacebar/enter from triggering if dialog itself is focused
|
||||||
|
if (e.target instanceof HTMLDialogElement && [' ', 'Enter'].includes(e.key)) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle escape key to close modal
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
context.setShow(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow other keydown handlers to run
|
||||||
|
// props.onKeyDown?.(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dialog
|
||||||
|
{...props}
|
||||||
|
id={`${context.localId}-root`}
|
||||||
|
aria-labelledby={`${context.localId}-title`}
|
||||||
|
aria-describedby={`${context.localId}-description`}
|
||||||
|
data-state={context.show() ? 'open' : 'closed'}
|
||||||
|
data-open={context.show() ? '' : undefined}
|
||||||
|
data-closed={!context.show() ? '' : undefined}
|
||||||
|
role={context.alert === true ? 'alertdialog' : 'dialog'}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
ref={setPanelRef}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleBackdropClick(e);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
34
packages/www/src/ui/modal/modal-root.tsx
Normal file
34
packages/www/src/ui/modal/modal-root.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
|
||||||
|
import { ModalContext } from './modal-context';
|
||||||
|
import { Accessor, ComponentProps, createSignal, createUniqueId, splitProps } from 'solid-js';
|
||||||
|
|
||||||
|
type ModalRootProps = {
|
||||||
|
onShow?: () => void;
|
||||||
|
onClose?: () => void;
|
||||||
|
'bind:show'?: Accessor<boolean>;
|
||||||
|
closeOnBackdropClick?: boolean;
|
||||||
|
alert?: boolean;
|
||||||
|
} & ComponentProps<'div'>;
|
||||||
|
|
||||||
|
export const HModalRoot = (props: ModalRootProps) => {
|
||||||
|
const localId = createUniqueId();
|
||||||
|
|
||||||
|
const [modalProps, divProps] = splitProps(props, [
|
||||||
|
'bind:show',
|
||||||
|
'closeOnBackdropClick',
|
||||||
|
'onShow',
|
||||||
|
'onClose',
|
||||||
|
'alert',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [defaultShowSig, setDefaultShowSig] = createSignal<boolean>(false);
|
||||||
|
const show = props["bind:show"] ?? defaultShowSig;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalContext.Provider value={{ ...modalProps, setShow: setDefaultShowSig, show, localId }} >
|
||||||
|
<div {...divProps}>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
</ModalContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
24
packages/www/src/ui/modal/modal-trigger.tsx
Normal file
24
packages/www/src/ui/modal/modal-trigger.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { useModal } from './modal-context';
|
||||||
|
import { ComponentProps } from 'solid-js';
|
||||||
|
|
||||||
|
export const HModalTrigger = (props: ComponentProps<"button">) => {
|
||||||
|
const modal = useModal();
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
modal.setShow((prev) => !prev);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-label="Open Theme Customization Panel"
|
||||||
|
aria-expanded={modal.show()}
|
||||||
|
data-open={modal.show() ? '' : undefined}
|
||||||
|
data-closed={!modal.show() ? '' : undefined}
|
||||||
|
onClick={[handleClick, props.onClick]}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
131
packages/www/src/ui/modal/use-modal.tsx
Normal file
131
packages/www/src/ui/modal/use-modal.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { FocusTrap, createFocusTrap } from 'focus-trap';
|
||||||
|
|
||||||
|
export type WidthState = {
|
||||||
|
width: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
import { enableBodyScroll, disableBodyScroll } from 'body-scroll-lock-upgrade';
|
||||||
|
|
||||||
|
export function useModal() {
|
||||||
|
/**
|
||||||
|
* Listens for animation/transition events in order to
|
||||||
|
* remove Animation-CSS-Classes after animation/transition ended.
|
||||||
|
*/
|
||||||
|
const supportClosingAnimation = (modal: HTMLDialogElement) => {
|
||||||
|
modal.dataset.closing = '';
|
||||||
|
modal.classList.add('modal-closing');
|
||||||
|
|
||||||
|
const { animationDuration, transitionDuration } = getComputedStyle(modal);
|
||||||
|
|
||||||
|
if (animationDuration !== '0s') {
|
||||||
|
modal.addEventListener(
|
||||||
|
'animationend',
|
||||||
|
(e) => {
|
||||||
|
if (e.target === modal) {
|
||||||
|
delete modal.dataset.closing;
|
||||||
|
modal.classList.remove('modal-closing');
|
||||||
|
enableBodyScroll(modal);
|
||||||
|
modal.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
} else if (transitionDuration !== '0s') {
|
||||||
|
modal.addEventListener(
|
||||||
|
'transitionend',
|
||||||
|
(e) => {
|
||||||
|
if (e.target === modal) {
|
||||||
|
delete modal.dataset.closing;
|
||||||
|
modal.classList.remove('modal-closing');
|
||||||
|
enableBodyScroll(modal);
|
||||||
|
modal.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
} else if (animationDuration === '0s' && transitionDuration === '0s') {
|
||||||
|
delete modal.dataset.closing;
|
||||||
|
modal.classList.remove('modal-closing');
|
||||||
|
enableBodyScroll(modal);
|
||||||
|
modal.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Traps the focus of the given Modal
|
||||||
|
* @returns FocusTrap
|
||||||
|
*/
|
||||||
|
const trapFocus = (modal: HTMLDialogElement): FocusTrap => {
|
||||||
|
return createFocusTrap(modal, { escapeDeactivates: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
const activateFocusTrap = (focusTrap: FocusTrap | null) => {
|
||||||
|
try {
|
||||||
|
focusTrap?.activate();
|
||||||
|
} catch {
|
||||||
|
// Activating the focus trap throws if no tabbable elements are inside the container.
|
||||||
|
// If this is the case we are fine with not activating the focus trap.
|
||||||
|
// That's why we ignore the thrown error.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deactivateFocusTrap = (focusTrap: FocusTrap | null) => {
|
||||||
|
focusTrap?.deactivate();
|
||||||
|
focusTrap = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows the given Modal.
|
||||||
|
* Applies a CSS-Class to animate the modal-showing.
|
||||||
|
* Calls the given callback that is executed after the Modal has been opened.
|
||||||
|
*/
|
||||||
|
const showModal = async (modal: HTMLDialogElement) => {
|
||||||
|
disableBodyScroll(modal, { reserveScrollBarGap: true });
|
||||||
|
modal.showModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes the given Modal.
|
||||||
|
* Applies a CSS-Class to animate the Modal-closing.
|
||||||
|
* Calls the given callback that is executed after the Modal has been closed.
|
||||||
|
*/
|
||||||
|
const closeModal = async (modal: HTMLDialogElement) => {
|
||||||
|
await supportClosingAnimation(modal);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if the backdrop of the Modal has been clicked.
|
||||||
|
*/
|
||||||
|
const wasModalBackdropClicked = (modal: HTMLDialogElement | undefined, clickEvent: MouseEvent): boolean => {
|
||||||
|
if (!modal) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = modal.getBoundingClientRect();
|
||||||
|
|
||||||
|
const wasBackdropClicked =
|
||||||
|
rect.left > clickEvent.clientX ||
|
||||||
|
rect.right < clickEvent.clientX ||
|
||||||
|
rect.top > clickEvent.clientY ||
|
||||||
|
rect.bottom < clickEvent.clientY;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the inside focusable elements are not prevented, such as a button it will also fire a click event.
|
||||||
|
*
|
||||||
|
* Hitting the enter or space keys on a button inside of the dialog for example, will fire a "pointer" event. In reality, it fires our onClick$ handler because we have not prevented the default behavior.
|
||||||
|
*
|
||||||
|
* This is why we check if the pointerId is -1.
|
||||||
|
**/
|
||||||
|
return (clickEvent as PointerEvent).pointerId === -1 ? false : wasBackdropClicked;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
trapFocus,
|
||||||
|
activateFocusTrap,
|
||||||
|
deactivateFocusTrap,
|
||||||
|
showModal,
|
||||||
|
closeModal,
|
||||||
|
wasModalBackdropClicked,
|
||||||
|
supportClosingAnimation,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -89,7 +89,7 @@ const font = {
|
|||||||
mono_2xl: "1.375rem",
|
mono_2xl: "1.375rem",
|
||||||
"2xl": "1.5rem",
|
"2xl": "1.5rem",
|
||||||
"3xl": "1.875rem",
|
"3xl": "1.875rem",
|
||||||
"4xl": "2.25rem",
|
"4xl": "2rem",
|
||||||
"5xl": "3rem",
|
"5xl": "3rem",
|
||||||
"6xl": "3.75rem",
|
"6xl": "3.75rem",
|
||||||
"7xl": "4.5rem",
|
"7xl": "4.5rem",
|
||||||
@@ -218,13 +218,16 @@ const light = (() => {
|
|||||||
const brand = "#FF4F01"
|
const brand = "#FF4F01"
|
||||||
|
|
||||||
const background = {
|
const background = {
|
||||||
d100: '#f5f5f5',
|
d100: 'rgba(255,255,255,0.8)',
|
||||||
d200: 'oklch(from #f5f5f5 calc(l + (-0.06 * clamp(0, calc((l - 0.714) * 1000), 1) + 0.03)) c h)'
|
d200: '#f4f5f6',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const headerGradient = "linear-gradient(rgba(66, 144, 243, 0.2) 0%, rgba(206, 127, 243, 0.1) 52.58%, rgba(248, 236, 215, 0) 100%)"
|
||||||
|
|
||||||
const contrastFg = '#ffffff';
|
const contrastFg = '#ffffff';
|
||||||
const focusBorder = `0 0 0 1px ${grayAlpha.d600}, 0px 0px 0px 4px rgba(0,0,0,0.16)`;
|
const focusBorder = `0 0 0 1px ${grayAlpha.d600}, 0px 0px 0px 4px rgba(0,0,0,0.16)`;
|
||||||
const focusColor = blue.d700
|
const focusColor = blue.d700
|
||||||
|
const hoverColor = "hsl(0,0%,22%)"
|
||||||
|
|
||||||
const text = {
|
const text = {
|
||||||
primary: {
|
primary: {
|
||||||
@@ -257,7 +260,9 @@ const light = (() => {
|
|||||||
focusColor,
|
focusColor,
|
||||||
d1000,
|
d1000,
|
||||||
brand,
|
brand,
|
||||||
text
|
text,
|
||||||
|
headerGradient,
|
||||||
|
hoverColor
|
||||||
};
|
};
|
||||||
})()
|
})()
|
||||||
|
|
||||||
@@ -380,13 +385,15 @@ const dark = (() => {
|
|||||||
const brand = "#FF4F01"
|
const brand = "#FF4F01"
|
||||||
|
|
||||||
const background = {
|
const background = {
|
||||||
d200: '#171717',
|
d100: "rgba(255,255,255,0.04)",
|
||||||
d100: "oklch(from #171717 calc(l + (-0.06 * clamp(0, calc((l - 0.714) * 1000), 1) + 0.03)) c h)"
|
d200: 'rgb(19,21,23)',
|
||||||
};
|
};
|
||||||
|
|
||||||
const contrastFg = '#ffffff';
|
const contrastFg = '#ffffff';
|
||||||
const focusBorder = `0 0 0 1px ${grayAlpha.d600}, 0px 0px 0px 4px rgba(255,255,255,0.24)`;
|
const focusBorder = `0 0 0 1px ${grayAlpha.d600}, 0px 0px 0px 4px rgba(255,255,255,0.24)`;
|
||||||
const focusColor = blue.d900
|
const focusColor = blue.d900
|
||||||
|
const hoverColor = "hsl(0,0%,80%)"
|
||||||
|
const headerGradient = "linear-gradient(rgba(66, 144, 243, 0.2) 0%, rgba(239, 148, 225, 0.1) 50%, rgba(191, 124, 7, 0) 100%)"
|
||||||
|
|
||||||
const text = {
|
const text = {
|
||||||
primary: {
|
primary: {
|
||||||
@@ -419,7 +426,9 @@ const dark = (() => {
|
|||||||
focusColor,
|
focusColor,
|
||||||
d1000,
|
d1000,
|
||||||
text,
|
text,
|
||||||
brand
|
brand,
|
||||||
|
headerGradient,
|
||||||
|
hoverColor
|
||||||
};
|
};
|
||||||
})()
|
})()
|
||||||
|
|
||||||
|
|||||||
1
packages/zero/.gitignore
vendored
Normal file
1
packages/zero/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
sync-replica*
|
||||||
1
packages/zero/.permissions.sql
Normal file
1
packages/zero/.permissions.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
UPDATE zero.permissions SET permissions = '{"tables":{"member":{"row":{"select":[["allow",{"type":"correlatedSubquery","related":{"system":"permissions","correlation":{"parentField":["team_id"],"childField":["team_id"]},"subquery":{"table":"member","alias":"zsubq_members","where":{"type":"simple","left":{"type":"column","name":"email"},"right":{"type":"static","anchor":"authData","field":"sub"},"op":"="},"orderBy":[["team_id","asc"],["id","asc"]]}},"op":"EXISTS"}]],"update":{}}},"team":{"row":{"select":[["allow",{"type":"correlatedSubquery","related":{"system":"permissions","correlation":{"parentField":["id"],"childField":["team_id"]},"subquery":{"table":"member","alias":"zsubq_members","where":{"type":"simple","left":{"type":"column","name":"email"},"right":{"type":"static","anchor":"authData","field":"sub"},"op":"="},"orderBy":[["team_id","asc"],["id","asc"]]}},"op":"EXISTS"}]],"update":{}}}}}';
|
||||||
12
packages/zero/package.json
Normal file
12
packages/zero/package.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "@nestri/zero",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"@rocicorp/zero": "*",
|
||||||
|
"@nestri/core": "*"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "zero-deploy-permissions --output-format=sql --output-file=.permissions.sql && zero-deploy-permissions && zero-cache"
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user