mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-11 00:05:36 +02:00
⭐feat(auth): Update the authentication UI (#153)
We added a new Auth UI, with all the business logic to handle profiles and such... it works alright
This commit is contained in:
16
apps/docs/sst-env.d.ts
vendored
16
apps/docs/sst-env.d.ts
vendored
@@ -21,6 +21,22 @@ declare module "sst" {
|
||||
"CloudflareAuthKV": {
|
||||
"type": "sst.cloudflare.Kv"
|
||||
}
|
||||
"DiscordClientID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"DiscordClientSecret": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GithubClientID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GithubClientSecret": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"InstantAdminToken": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
|
||||
@@ -27,7 +27,7 @@ export default component$(() => {
|
||||
issuer: "https://auth.lauryn.dev.nestri.io"
|
||||
})
|
||||
|
||||
const { url } = await client.authorize("http://localhost:5173/login", "token", { pkce: true })
|
||||
const { url } = await client.authorize("http://localhost:5173/login-test", "token", { pkce: true })
|
||||
window.location.href = url
|
||||
})
|
||||
|
||||
16
apps/www/sst-env.d.ts
vendored
16
apps/www/sst-env.d.ts
vendored
@@ -21,6 +21,22 @@ declare module "sst" {
|
||||
"CloudflareAuthKV": {
|
||||
"type": "sst.cloudflare.Kv"
|
||||
}
|
||||
"DiscordClientID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"DiscordClientSecret": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GithubClientID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GithubClientSecret": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"InstantAdminToken": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
|
||||
@@ -30,7 +30,11 @@ export const auth = new sst.cloudflare.Worker("Auth", {
|
||||
authFingerprintKey,
|
||||
secret.InstantAdminToken,
|
||||
secret.InstantAppId,
|
||||
secret.LoopsApiKey
|
||||
secret.LoopsApiKey,
|
||||
secret.GithubClientID,
|
||||
secret.GithubClientSecret,
|
||||
secret.DiscordClientID,
|
||||
secret.DiscordClientSecret,
|
||||
],
|
||||
handler: "./packages/functions/src/auth.ts",
|
||||
url: true,
|
||||
@@ -43,7 +47,7 @@ export const api = new sst.cloudflare.Worker("Api", {
|
||||
authFingerprintKey,
|
||||
secret.InstantAdminToken,
|
||||
secret.InstantAppId,
|
||||
secret.LoopsApiKey
|
||||
secret.LoopsApiKey,
|
||||
],
|
||||
url: true,
|
||||
handler: "./packages/functions/src/api/index.ts",
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
export const secret = {
|
||||
InstantAdminToken: new sst.Secret("InstantAdminToken"),
|
||||
InstantAppId: new sst.Secret("InstantAppId"),
|
||||
LoopsApiKey: new sst.Secret("LoopsApiKey")
|
||||
LoopsApiKey: new sst.Secret("LoopsApiKey"),
|
||||
GithubClientSecret: new sst.Secret("GithubClientSecret"),
|
||||
GithubClientID: new sst.Secret("GithubClientID"),
|
||||
DiscordClientSecret: new sst.Secret("DiscordClientSecret"),
|
||||
DiscordClientID: new sst.Secret("DiscordClientID"),
|
||||
};
|
||||
|
||||
export const allSecrets = Object.values(secret);
|
||||
@@ -11,6 +11,14 @@ const _schema = i.schema({
|
||||
deletedAt: i.date().optional().indexed(),
|
||||
createdAt: i.date()
|
||||
}),
|
||||
profiles: i.entity({
|
||||
avatarUrl: i.string().optional(),
|
||||
username: i.string().indexed(),
|
||||
ownerID: i.string().unique().indexed(),
|
||||
updatedAt: i.date(),
|
||||
createdAt: i.date(),
|
||||
discriminator: i.string().indexed()
|
||||
}),
|
||||
games: i.entity({
|
||||
name: i.string(),
|
||||
steamID: i.number().unique().indexed(),
|
||||
@@ -23,6 +31,10 @@ const _schema = i.schema({
|
||||
}),
|
||||
},
|
||||
links: {
|
||||
UserProfiles: {
|
||||
forward: { on: "profiles", has: "one", label: "owner" },
|
||||
reverse: { on: "$users", has: "one", label: "profile" }
|
||||
},
|
||||
UserMachines: {
|
||||
forward: { on: "machines", has: "one", label: "owner" },
|
||||
reverse: { on: "$users", has: "many", label: "machines" }
|
||||
|
||||
@@ -16,6 +16,6 @@
|
||||
"zod-openapi": "^4.2.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@instantdb/admin": "^0.17.3"
|
||||
"@instantdb/admin": "^0.17.7"
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,15 @@ export module Examples {
|
||||
email: "john@example.com",
|
||||
};
|
||||
|
||||
export const Profile = {
|
||||
id: "0bfcb712-df13-4454-81a8-fbee66eddca4",
|
||||
username: "janedoe47",
|
||||
avatarUrl: "https://cdn.discordapp.com/avatars/xxxxxxx/xxxxxxx.png",
|
||||
discriminator: 12, //it needs to be two digits
|
||||
createdAt: '2025-01-04T11:56:23.902Z',
|
||||
updatedAt: '2025-01-09T01:56:23.902Z'
|
||||
}
|
||||
|
||||
export const Machine = {
|
||||
id: "0bfcb712-df13-4454-81a8-fbee66eddca4",
|
||||
hostname: "DESKTOP-EUO8VSF",
|
||||
|
||||
@@ -29,7 +29,7 @@ export module Machines {
|
||||
})
|
||||
.openapi({
|
||||
ref: "Machine",
|
||||
description: "Represents a a physical or virtual machine connected to the Nestri network..",
|
||||
description: "Represents a physical or virtual machine connected to the Nestri network..",
|
||||
example: Examples.Machine,
|
||||
});
|
||||
|
||||
|
||||
232
packages/core/src/profile/index.ts
Normal file
232
packages/core/src/profile/index.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { z } from "zod"
|
||||
import { fn } from "../utils";
|
||||
import { Common } from "../common";
|
||||
import { Examples } from "../examples";
|
||||
import databaseClient from "../database";
|
||||
import { groupBy, map, pipe, values } from "remeda"
|
||||
import { id as createID } from "@instantdb/admin";
|
||||
|
||||
export module Profiles {
|
||||
const MAX_ATTEMPTS = 50;
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().openapi({
|
||||
description: Common.IdDescription,
|
||||
example: Examples.Machine.id,
|
||||
}),
|
||||
username: z.string().openapi({
|
||||
description: "The user's unique username",
|
||||
example: Examples.Profile.username,
|
||||
}),
|
||||
avatarUrl: z.string().or(z.undefined()).openapi({
|
||||
description: "The url to the profile picture.",
|
||||
example: Examples.Profile.username,
|
||||
}),
|
||||
discriminator: z.string().or(z.number()).openapi({
|
||||
description: "The number discriminator for each username",
|
||||
example: Examples.Profile.discriminator,
|
||||
}),
|
||||
createdAt: z.string().or(z.number()).openapi({
|
||||
description: "The time when this profile was first created",
|
||||
example: Examples.Profile.createdAt,
|
||||
}),
|
||||
updatedAt: z.string().or(z.number()).openapi({
|
||||
description: "The time when this profile was last edited",
|
||||
example: Examples.Profile.updatedAt,
|
||||
})
|
||||
})
|
||||
.openapi({
|
||||
ref: "Profile",
|
||||
description: "Represents a profile of a user on Nestri",
|
||||
example: Examples.Profile,
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const sanitizeUsername = (username: string): string => {
|
||||
// Remove spaces and numbers
|
||||
return username.replace(/[\s0-9]/g, '');
|
||||
};
|
||||
|
||||
export const generateDiscriminator = (): string => {
|
||||
return Math.floor(Math.random() * 100).toString().padStart(2, '0');
|
||||
};
|
||||
|
||||
export const isValidDiscriminator = (discriminator: string): boolean => {
|
||||
return /^\d{2}$/.test(discriminator);
|
||||
};
|
||||
|
||||
export const fromUsername = fn(z.string(), async (input) => {
|
||||
const sanitizedUsername = sanitizeUsername(input);
|
||||
|
||||
const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
profiles: {
|
||||
$: {
|
||||
where: {
|
||||
username: sanitizedUsername,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const res = await db.query(query)
|
||||
|
||||
const profiles = res.profiles
|
||||
|
||||
if (!profiles || profiles.length == 0) {
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
return pipe(
|
||||
profiles,
|
||||
groupBy(x => x.id),
|
||||
values(),
|
||||
map((group): Info => ({
|
||||
id: group[0].id,
|
||||
username: group[0].username,
|
||||
createdAt: group[0].createdAt,
|
||||
discriminator: group[0].discriminator,
|
||||
updatedAt: group[0].updatedAt
|
||||
}))
|
||||
)
|
||||
})
|
||||
|
||||
export const findAvailableDiscriminator = fn(z.string(), async (input) => {
|
||||
const db = databaseClient()
|
||||
const username = sanitizeUsername(input);
|
||||
|
||||
for (let i = 0; i < MAX_ATTEMPTS; i++) {
|
||||
const discriminator = generateDiscriminator();
|
||||
const query = {
|
||||
profiles: {
|
||||
$: {
|
||||
where: {
|
||||
username,
|
||||
discriminator
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const res = await db.query(query)
|
||||
const profiles = res.profiles
|
||||
if (profiles.length === 0) {
|
||||
return discriminator;
|
||||
}
|
||||
}
|
||||
return null; // No available discriminators
|
||||
|
||||
})
|
||||
|
||||
export const create = fn(z.object({ username: z.string(), customDiscriminator: z.string().optional(), avatarUrl: z.string().optional(), owner: z.string() }), async (input) => {
|
||||
const username = sanitizeUsername(input.username);
|
||||
|
||||
// if (!username || username.length < 2 || username.length > 32) {
|
||||
// // throw new Error('Invalid username length');
|
||||
// }
|
||||
|
||||
const db = databaseClient()
|
||||
const id = createID()
|
||||
const now = new Date().toISOString()
|
||||
|
||||
let discriminator: string | null;
|
||||
if (input.customDiscriminator) {
|
||||
if (!isValidDiscriminator(input.customDiscriminator)) {
|
||||
console.error('Invalid discriminator format')
|
||||
return null
|
||||
// throw new Error('Invalid discriminator format');
|
||||
}
|
||||
|
||||
const query = {
|
||||
profiles: {
|
||||
$: {
|
||||
where: {
|
||||
username,
|
||||
discriminator: input.customDiscriminator
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const res = await db.query(query)
|
||||
const profiles = res.profiles
|
||||
if (profiles.length != 0) {
|
||||
console.error("Username and discriminator combination already taken ")
|
||||
return null
|
||||
// throw new Error('Username and discriminator combination already taken');
|
||||
}
|
||||
|
||||
discriminator = input.customDiscriminator
|
||||
} else {
|
||||
// Generate a random available discriminator
|
||||
discriminator = await findAvailableDiscriminator(username);
|
||||
|
||||
if (!discriminator) {
|
||||
console.error("No available discriminators for this username ")
|
||||
return null
|
||||
// throw new Error('No available discriminators for this username');
|
||||
}
|
||||
}
|
||||
|
||||
return await db.transact(
|
||||
db.tx.profiles[id]!.update({
|
||||
username,
|
||||
avatarUrl: input.avatarUrl,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
ownerID: input.owner,
|
||||
discriminator,
|
||||
}).link({ owner: input.owner })
|
||||
)
|
||||
})
|
||||
|
||||
export const getFullUsername = async (username: string) => {
|
||||
const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
profiles: {
|
||||
$: {
|
||||
where: {
|
||||
username,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const res = await db.query(query)
|
||||
const profiles = res.profiles
|
||||
|
||||
if (!profiles || profiles.length === 0) {
|
||||
console.error('User not found')
|
||||
return null
|
||||
// throw new Error('User not found');
|
||||
}
|
||||
|
||||
return `${profiles[0]?.username}#${profiles[0]?.discriminator}`;
|
||||
}
|
||||
|
||||
export const getProfile = async (ownerID: string) => {
|
||||
|
||||
const db = databaseClient()
|
||||
|
||||
const query = {
|
||||
profiles: {
|
||||
$: {
|
||||
where: {
|
||||
ownerID
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
const res = await db.query(query)
|
||||
|
||||
const profiles = res.profiles
|
||||
|
||||
if (!profiles || profiles.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return profiles
|
||||
}
|
||||
};
|
||||
16
packages/core/sst-env.d.ts
vendored
16
packages/core/sst-env.d.ts
vendored
@@ -21,6 +21,22 @@ declare module "sst" {
|
||||
"CloudflareAuthKV": {
|
||||
"type": "sst.cloudflare.Kv"
|
||||
}
|
||||
"DiscordClientID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"DiscordClientSecret": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GithubClientID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GithubClientSecret": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"InstantAdminToken": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
|
||||
@@ -3,18 +3,20 @@ import {
|
||||
type ExecutionContext,
|
||||
type KVNamespace,
|
||||
} from "@cloudflare/workers-types"
|
||||
import { Select } from "./ui/select";
|
||||
import { subjects } from "./subjects"
|
||||
import { User } from "@nestri/core/user/index"
|
||||
import { Email } from "@nestri/core/email/index"
|
||||
import { PasswordUI } from "./ui/password"
|
||||
import { authorizer } from "@openauthjs/openauth"
|
||||
import { type CFRequest } from "@nestri/core/types"
|
||||
import { Select } from "@openauthjs/openauth/ui/select";
|
||||
import { PasswordUI } from "@openauthjs/openauth/ui/password"
|
||||
import type { Adapter } from "@openauthjs/openauth/adapter/adapter"
|
||||
import { PasswordAdapter } from "@openauthjs/openauth/adapter/password"
|
||||
import { CloudflareStorage } from "@openauthjs/openauth/storage/cloudflare"
|
||||
import { GithubAdapter } from "./ui/adapters/github";
|
||||
import { DiscordAdapter } from "./ui/adapters/discord";
|
||||
import { Machines } from "@nestri/core/machine/index"
|
||||
|
||||
import { PasswordAdapter } from "./ui/adapters/password"
|
||||
import { type Adapter } from "@openauthjs/openauth/adapter/adapter"
|
||||
import { CloudflareStorage } from "@openauthjs/openauth/storage/cloudflare"
|
||||
import { handleDiscord, handleGithub } from "./utils";
|
||||
import { User } from "@nestri/core/user/index"
|
||||
import { Profiles } from "@nestri/core/profile/index"
|
||||
interface Env {
|
||||
CloudflareAuthKV: KVNamespace
|
||||
}
|
||||
@@ -30,6 +32,15 @@ export type CodeAdapterState =
|
||||
claims: Record<string, string>
|
||||
}
|
||||
|
||||
type OauthUser = {
|
||||
primary: {
|
||||
email: any;
|
||||
primary: any;
|
||||
verified: any;
|
||||
};
|
||||
avatar: any;
|
||||
username: any;
|
||||
}
|
||||
export default {
|
||||
async fetch(request: CFRequest, env: Env, ctx: ExecutionContext) {
|
||||
// const location = `${request.cf.country},${request.cf.continent}`
|
||||
@@ -64,11 +75,21 @@ export default {
|
||||
}),
|
||||
subjects,
|
||||
providers: {
|
||||
github: GithubAdapter({
|
||||
clientID: Resource.GithubClientID.value,
|
||||
clientSecret: Resource.GithubClientSecret.value,
|
||||
scopes: ["user:email"]
|
||||
}),
|
||||
discord: DiscordAdapter({
|
||||
clientID: Resource.DiscordClientID.value,
|
||||
clientSecret: Resource.DiscordClientSecret.value,
|
||||
scopes: ["email", "identify"]
|
||||
}),
|
||||
password: PasswordAdapter(
|
||||
PasswordUI({
|
||||
sendCode: async (email, code) => {
|
||||
console.log("email & code:", email, code)
|
||||
await Email.send(email, code)
|
||||
// await Email.send(email, code)
|
||||
},
|
||||
}),
|
||||
),
|
||||
@@ -116,27 +137,83 @@ export default {
|
||||
id: machineID,
|
||||
fingerprint: value.fingerprint
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return await ctx.subject("device", {
|
||||
id: exists.id,
|
||||
fingerprint: value.fingerprint
|
||||
})
|
||||
|
||||
|
||||
}
|
||||
|
||||
const email = value.email;
|
||||
|
||||
if (email) {
|
||||
const token = await User.create(email);
|
||||
const user = await User.fromEmail(email);
|
||||
if (value.provider === "password") {
|
||||
const email = value.email
|
||||
const username = value.username
|
||||
const token = await User.create(email)
|
||||
const usr = await User.fromEmail(email);
|
||||
const exists = await Profiles.getProfile(usr.id)
|
||||
if(username && !exists){
|
||||
await Profiles.create({ owner: usr.id, username })
|
||||
}
|
||||
|
||||
return await ctx.subject("user", {
|
||||
accessToken: token,
|
||||
userID: user.id
|
||||
userID: usr.id
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
let user = undefined as OauthUser | undefined;
|
||||
|
||||
if (value.provider === "github") {
|
||||
const access = value.tokenset.access;
|
||||
user = await handleGithub(access)
|
||||
// console.log("user", user)
|
||||
}
|
||||
|
||||
if (value.provider === "discord") {
|
||||
const access = value.tokenset.access
|
||||
user = await handleDiscord(access)
|
||||
// console.log("user", user)
|
||||
}
|
||||
|
||||
if (user) {
|
||||
try {
|
||||
const token = await User.create(user.primary.email)
|
||||
const usr = await User.fromEmail(user.primary.email);
|
||||
const exists = await Profiles.getProfile(usr.id)
|
||||
console.log("exists",exists)
|
||||
if (!exists) {
|
||||
await Profiles.create({ owner: usr.id, avatarUrl: user.avatar, username: user.username })
|
||||
}
|
||||
|
||||
return await ctx.subject("user", {
|
||||
accessToken: token,
|
||||
userID: usr.id
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("error registering the user", error)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// if (email) {
|
||||
// console.log("email", email)
|
||||
// // value.username && console.log("username", value.username)
|
||||
|
||||
// }
|
||||
|
||||
// if (email) {
|
||||
// const token = await User.create(email);
|
||||
// const user = await User.fromEmail(email);
|
||||
|
||||
// return await ctx.subject("user", {
|
||||
// accessToken: token,
|
||||
// userID: user.id
|
||||
// });
|
||||
// }
|
||||
|
||||
throw new Error("This is not implemented yet");
|
||||
},
|
||||
}).fetch(request, env, ctx)
|
||||
|
||||
12
packages/functions/src/ui/adapters/discord.ts
Normal file
12
packages/functions/src/ui/adapters/discord.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Oauth2Adapter, type Oauth2WrappedConfig } from "./oauth2"
|
||||
|
||||
export function DiscordAdapter(config: Oauth2WrappedConfig) {
|
||||
return Oauth2Adapter({
|
||||
type: "discord",
|
||||
...config,
|
||||
endpoint: {
|
||||
authorization: "https://discord.com/oauth2/authorize",
|
||||
token: "https://discord.com/api/oauth2/token",
|
||||
},
|
||||
})
|
||||
}
|
||||
12
packages/functions/src/ui/adapters/github.ts
Normal file
12
packages/functions/src/ui/adapters/github.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Oauth2Adapter, type Oauth2WrappedConfig } from "./oauth2"
|
||||
|
||||
export function GithubAdapter(config: Oauth2WrappedConfig) {
|
||||
return Oauth2Adapter({
|
||||
...config,
|
||||
type: "github",
|
||||
endpoint: {
|
||||
authorization: "https://github.com/login/oauth/authorize",
|
||||
token: "https://github.com/login/oauth/access_token",
|
||||
},
|
||||
})
|
||||
}
|
||||
137
packages/functions/src/ui/adapters/oauth2.tsx
Normal file
137
packages/functions/src/ui/adapters/oauth2.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
/** @jsxImportSource hono/jsx */
|
||||
import { Layout } from "../base"
|
||||
import { OauthError } from "@openauthjs/openauth/error"
|
||||
import { getRelativeUrl } from "@openauthjs/openauth/util"
|
||||
import { type Adapter } from "@openauthjs/openauth/adapter/adapter"
|
||||
|
||||
export interface Oauth2Config {
|
||||
type?: string
|
||||
clientID: string
|
||||
clientSecret: string
|
||||
endpoint: {
|
||||
authorization: string
|
||||
token: string
|
||||
}
|
||||
scopes: string[]
|
||||
query?: Record<string, string>
|
||||
}
|
||||
|
||||
export type Oauth2WrappedConfig = Omit<Oauth2Config, "endpoint" | "name">
|
||||
|
||||
export interface Oauth2Token {
|
||||
access: string
|
||||
refresh: string
|
||||
expiry: number
|
||||
raw: Record<string, any>
|
||||
}
|
||||
|
||||
interface AdapterState {
|
||||
state: string
|
||||
redirect: string
|
||||
}
|
||||
|
||||
export function Oauth2Adapter(
|
||||
config: Oauth2Config,
|
||||
): Adapter<{ tokenset: Oauth2Token; clientID: string }> {
|
||||
const query = config.query || {}
|
||||
return {
|
||||
type: config.type || "oauth2",
|
||||
init(routes, ctx) {
|
||||
routes.get("/authorize", async (c) => {
|
||||
const state = crypto.randomUUID()
|
||||
await ctx.set<AdapterState>(c, "adapter", 60 * 10, {
|
||||
state,
|
||||
redirect: getRelativeUrl(c, "./popup"),
|
||||
})
|
||||
const authorization = new URL(config.endpoint.authorization)
|
||||
authorization.searchParams.set("client_id", config.clientID)
|
||||
authorization.searchParams.set(
|
||||
"redirect_uri",
|
||||
getRelativeUrl(c, "./popup"),
|
||||
)
|
||||
authorization.searchParams.set("response_type", "code")
|
||||
authorization.searchParams.set("state", state)
|
||||
authorization.searchParams.set("scope", config.scopes.join(" "))
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
authorization.searchParams.set(key, value)
|
||||
}
|
||||
return c.redirect(authorization.toString())
|
||||
})
|
||||
|
||||
routes.get("/popup", async (c) => {
|
||||
const jsx = (
|
||||
<Layout page="popup">
|
||||
<div data-component="popup">
|
||||
<div data-component="spinner">
|
||||
<div>
|
||||
{new Array(12).fill(0).map((i, k) => (
|
||||
<div key={k} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
Nestri is verifying your connection...
|
||||
</div>
|
||||
</Layout>
|
||||
) as string
|
||||
return new Response(jsx.toString(), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/html",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
routes.get("/callback", async (c) => {
|
||||
const adapter = (await ctx.get(c, "adapter")) as AdapterState
|
||||
const code = c.req.query("code")
|
||||
const state = c.req.query("state")
|
||||
const error = c.req.query("error")
|
||||
if (error) {
|
||||
console.log("oauth2 error", error)
|
||||
throw new OauthError(
|
||||
error.toString() as any,
|
||||
c.req.query("error_description")?.toString() || "",
|
||||
)
|
||||
}
|
||||
if (!adapter || !code || (adapter.state && state !== adapter.state))
|
||||
return c.redirect(getRelativeUrl(c, "./authorize"))
|
||||
const body = new URLSearchParams({
|
||||
client_id: config.clientID,
|
||||
client_secret: config.clientSecret,
|
||||
code,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: adapter.redirect,
|
||||
})
|
||||
const json: any = await fetch(config.endpoint.token, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: body.toString(),
|
||||
}).then((r) => r.json())
|
||||
if ("error" in json) {
|
||||
console.error("oauth2 error", error)
|
||||
throw new OauthError(json.error, json.error_description)
|
||||
}
|
||||
return ctx.success(c, {
|
||||
clientID: config.clientID,
|
||||
tokenset: {
|
||||
get access() {
|
||||
return json.access_token
|
||||
},
|
||||
get refresh() {
|
||||
return json.refresh_token
|
||||
},
|
||||
get expiry() {
|
||||
return json.expires_in
|
||||
},
|
||||
get raw() {
|
||||
return json
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
441
packages/functions/src/ui/adapters/password.ts
Normal file
441
packages/functions/src/ui/adapters/password.ts
Normal file
@@ -0,0 +1,441 @@
|
||||
import { Profiles } from "@nestri/core/profile/index"
|
||||
import { UnknownStateError } from "@openauthjs/openauth/error"
|
||||
import { Storage } from "@openauthjs/openauth/storage/storage"
|
||||
import { type Adapter } from "@openauthjs/openauth/adapter/adapter"
|
||||
import { generateUnbiasedDigits, timingSafeCompare } from "@openauthjs/openauth/random"
|
||||
|
||||
export interface PasswordHasher<T> {
|
||||
hash(password: string): Promise<T>
|
||||
verify(password: string, compare: T): Promise<boolean>
|
||||
}
|
||||
|
||||
export interface PasswordConfig {
|
||||
length?: number
|
||||
hasher?: PasswordHasher<any>
|
||||
login: (
|
||||
req: Request,
|
||||
form?: FormData,
|
||||
error?: PasswordLoginError,
|
||||
) => Promise<Response>
|
||||
register: (
|
||||
req: Request,
|
||||
state: PasswordRegisterState,
|
||||
form?: FormData,
|
||||
error?: PasswordRegisterError,
|
||||
) => Promise<Response>
|
||||
change: (
|
||||
req: Request,
|
||||
state: PasswordChangeState,
|
||||
form?: FormData,
|
||||
error?: PasswordChangeError,
|
||||
) => Promise<Response>
|
||||
sendCode: (email: string, code: string) => Promise<void>
|
||||
}
|
||||
|
||||
export type PasswordRegisterState =
|
||||
| {
|
||||
type: "start"
|
||||
}
|
||||
| {
|
||||
type: "code"
|
||||
code: string
|
||||
email: string
|
||||
password: string
|
||||
username: string
|
||||
}
|
||||
|
||||
export type PasswordRegisterError =
|
||||
| {
|
||||
type: "invalid_code"
|
||||
}
|
||||
| {
|
||||
type: "email_taken"
|
||||
}
|
||||
| {
|
||||
type: "invalid_email"
|
||||
}
|
||||
| {
|
||||
type: "invalid_password"
|
||||
}
|
||||
| {
|
||||
type: "invalid_username"
|
||||
}| {
|
||||
type: "username_taken"
|
||||
}
|
||||
|
||||
export type PasswordChangeState =
|
||||
| {
|
||||
type: "start"
|
||||
redirect: string
|
||||
}
|
||||
| {
|
||||
type: "code"
|
||||
code: string
|
||||
email: string
|
||||
redirect: string
|
||||
}
|
||||
| {
|
||||
type: "update"
|
||||
redirect: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export type PasswordChangeError =
|
||||
| {
|
||||
type: "invalid_email"
|
||||
}
|
||||
| {
|
||||
type: "invalid_code"
|
||||
}
|
||||
| {
|
||||
type: "invalid_password"
|
||||
}
|
||||
| {
|
||||
type: "password_mismatch"
|
||||
}
|
||||
|
||||
export type PasswordLoginError =
|
||||
| {
|
||||
type: "invalid_password"
|
||||
}
|
||||
| {
|
||||
type: "invalid_email"
|
||||
}
|
||||
|
||||
export function PasswordAdapter(config: PasswordConfig) {
|
||||
const hasher = config.hasher ?? ScryptHasher()
|
||||
function generate() {
|
||||
return generateUnbiasedDigits(6)
|
||||
}
|
||||
return {
|
||||
type: "password",
|
||||
init(routes, ctx) {
|
||||
routes.get("/authorize", async (c) =>
|
||||
ctx.forward(c, await config.login(c.req.raw)),
|
||||
)
|
||||
|
||||
routes.post("/authorize", async (c) => {
|
||||
const fd = await c.req.formData()
|
||||
async function error(err: PasswordLoginError) {
|
||||
return ctx.forward(c, await config.login(c.req.raw, fd, err))
|
||||
}
|
||||
const email = fd.get("email")?.toString()?.toLowerCase()
|
||||
if (!email) return error({ type: "invalid_email" })
|
||||
const hash = await Storage.get<HashedPassword>(ctx.storage, [
|
||||
"email",
|
||||
email,
|
||||
"password",
|
||||
])
|
||||
const password = fd.get("password")?.toString()
|
||||
if (!password || !hash || !(await hasher.verify(password, hash)))
|
||||
return error({ type: "invalid_password" })
|
||||
return ctx.success(
|
||||
c,
|
||||
{
|
||||
email: email,
|
||||
},
|
||||
{
|
||||
invalidate: async (subject) => {
|
||||
await Storage.set(
|
||||
ctx.storage,
|
||||
["email", email, "subject"],
|
||||
subject,
|
||||
)
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
routes.get("/register", async (c) => {
|
||||
const state: PasswordRegisterState = {
|
||||
type: "start",
|
||||
}
|
||||
await ctx.set(c, "adapter", 60 * 60 * 24, state)
|
||||
return ctx.forward(c, await config.register(c.req.raw, state))
|
||||
})
|
||||
|
||||
routes.post("/register", async (c) => {
|
||||
const fd = await c.req.formData()
|
||||
const email = fd.get("email")?.toString()?.toLowerCase()
|
||||
const action = fd.get("action")?.toString()
|
||||
const adapter = await ctx.get<PasswordRegisterState>(c, "adapter")
|
||||
|
||||
async function transition(
|
||||
next: PasswordRegisterState,
|
||||
err?: PasswordRegisterError,
|
||||
) {
|
||||
await ctx.set<PasswordRegisterState>(c, "adapter", 60 * 60 * 24, next)
|
||||
return ctx.forward(c, await config.register(c.req.raw, next, fd, err))
|
||||
}
|
||||
|
||||
if (action === "register" && adapter.type === "start") {
|
||||
const password = fd.get("password")?.toString()
|
||||
const username = fd.get("username")?.toString()
|
||||
const usernameRegex = /^[a-zA-Z]{1,32}$/;
|
||||
if (!email) return transition(adapter, { type: "invalid_email" })
|
||||
if (!username) return transition(adapter, { type: "invalid_username" })
|
||||
if (!password)
|
||||
return transition(adapter, { type: "invalid_password" })
|
||||
if (!usernameRegex.test(username))
|
||||
return transition(adapter, { type: "invalid_username" })
|
||||
const existing = await Storage.get(ctx.storage, [
|
||||
"email",
|
||||
email,
|
||||
"password",
|
||||
])
|
||||
if (existing) return transition(adapter, { type: "email_taken" })
|
||||
const existingUsername = await Profiles.fromUsername(username)
|
||||
if (existingUsername) return transition(adapter, { type: "username_taken" })
|
||||
const code = generate()
|
||||
await config.sendCode(email, code)
|
||||
return transition({
|
||||
type: "code",
|
||||
code,
|
||||
password: await hasher.hash(password),
|
||||
email,
|
||||
username
|
||||
})
|
||||
}
|
||||
|
||||
if (action === "verify" && adapter.type === "code") {
|
||||
const code = fd.get("code")?.toString()
|
||||
if (!code || !timingSafeCompare(code, adapter.code))
|
||||
return transition(adapter, { type: "invalid_code" })
|
||||
const existing = await Storage.get(ctx.storage, [
|
||||
"email",
|
||||
adapter.email,
|
||||
"password",
|
||||
])
|
||||
if (existing)
|
||||
return transition({ type: "start" }, { type: "email_taken" })
|
||||
await Storage.set(
|
||||
ctx.storage,
|
||||
["email", adapter.email, "password"],
|
||||
adapter.password,
|
||||
)
|
||||
return ctx.success(c, {
|
||||
email: adapter.email,
|
||||
username: adapter.username
|
||||
})
|
||||
}
|
||||
|
||||
return transition({ type: "start" })
|
||||
})
|
||||
|
||||
routes.get("/change", async (c) => {
|
||||
let redirect =
|
||||
c.req.query("redirect_uri") || getRelativeUrl(c, "./authorize")
|
||||
const state: PasswordChangeState = {
|
||||
type: "start",
|
||||
redirect,
|
||||
}
|
||||
await ctx.set(c, "adapter", 60 * 60 * 24, state)
|
||||
return ctx.forward(c, await config.change(c.req.raw, state))
|
||||
})
|
||||
|
||||
routes.post("/change", async (c) => {
|
||||
const fd = await c.req.formData()
|
||||
const action = fd.get("action")?.toString()
|
||||
const adapter = await ctx.get<PasswordChangeState>(c, "adapter")
|
||||
if (!adapter) throw new UnknownStateError()
|
||||
|
||||
async function transition(
|
||||
next: PasswordChangeState,
|
||||
err?: PasswordChangeError,
|
||||
) {
|
||||
await ctx.set<PasswordChangeState>(c, "adapter", 60 * 60 * 24, next)
|
||||
return ctx.forward(c, await config.change(c.req.raw, next, fd, err))
|
||||
}
|
||||
|
||||
if (action === "code") {
|
||||
const email = fd.get("email")?.toString()?.toLowerCase()
|
||||
if (!email)
|
||||
return transition(
|
||||
{ type: "start", redirect: adapter.redirect },
|
||||
{ type: "invalid_email" },
|
||||
)
|
||||
const code = generate()
|
||||
await config.sendCode(email, code)
|
||||
|
||||
return transition({
|
||||
type: "code",
|
||||
code,
|
||||
email,
|
||||
redirect: adapter.redirect,
|
||||
})
|
||||
}
|
||||
|
||||
if (action === "verify" && adapter.type === "code") {
|
||||
const code = fd.get("code")?.toString()
|
||||
if (!code || !timingSafeCompare(code, adapter.code))
|
||||
return transition(adapter, { type: "invalid_code" })
|
||||
return transition({
|
||||
type: "update",
|
||||
email: adapter.email,
|
||||
redirect: adapter.redirect,
|
||||
})
|
||||
}
|
||||
|
||||
if (action === "update" && adapter.type === "update") {
|
||||
const existing = await Storage.get(ctx.storage, [
|
||||
"email",
|
||||
adapter.email,
|
||||
"password",
|
||||
])
|
||||
if (!existing) return c.redirect(adapter.redirect, 302)
|
||||
|
||||
const password = fd.get("password")?.toString()
|
||||
const repeat = fd.get("repeat")?.toString()
|
||||
if (!password)
|
||||
return transition(adapter, { type: "invalid_password" })
|
||||
if (password !== repeat)
|
||||
return transition(adapter, { type: "password_mismatch" })
|
||||
|
||||
await Storage.set(
|
||||
ctx.storage,
|
||||
["email", adapter.email, "password"],
|
||||
await hasher.hash(password),
|
||||
)
|
||||
const subject = await Storage.get<string>(ctx.storage, [
|
||||
"email",
|
||||
adapter.email,
|
||||
"subject",
|
||||
])
|
||||
if (subject) await ctx.invalidate(subject)
|
||||
|
||||
return c.redirect(adapter.redirect, 302)
|
||||
}
|
||||
|
||||
return transition({ type: "start", redirect: adapter.redirect })
|
||||
})
|
||||
},
|
||||
} satisfies Adapter<{ email: string; username?:string }>
|
||||
}
|
||||
|
||||
import * as jose from "jose"
|
||||
import { TextEncoder } from "node:util"
|
||||
|
||||
interface HashedPassword {}
|
||||
|
||||
export function PBKDF2Hasher(opts?: { interations?: number }): PasswordHasher<{
|
||||
hash: string
|
||||
salt: string
|
||||
iterations: number
|
||||
}> {
|
||||
const iterations = opts?.interations ?? 600000
|
||||
return {
|
||||
async hash(password) {
|
||||
const encoder = new TextEncoder()
|
||||
const bytes = encoder.encode(password)
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16))
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
bytes,
|
||||
"PBKDF2",
|
||||
false,
|
||||
["deriveBits"],
|
||||
)
|
||||
const hash = await crypto.subtle.deriveBits(
|
||||
{
|
||||
name: "PBKDF2",
|
||||
hash: "SHA-256",
|
||||
salt: salt,
|
||||
iterations,
|
||||
},
|
||||
keyMaterial,
|
||||
256,
|
||||
)
|
||||
const hashBase64 = jose.base64url.encode(new Uint8Array(hash))
|
||||
const saltBase64 = jose.base64url.encode(salt)
|
||||
return {
|
||||
hash: hashBase64,
|
||||
salt: saltBase64,
|
||||
iterations,
|
||||
}
|
||||
},
|
||||
async verify(password, compare) {
|
||||
const encoder = new TextEncoder()
|
||||
const passwordBytes = encoder.encode(password)
|
||||
const salt = jose.base64url.decode(compare.salt)
|
||||
const params = {
|
||||
name: "PBKDF2",
|
||||
hash: "SHA-256",
|
||||
salt,
|
||||
iterations: compare.iterations,
|
||||
}
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
passwordBytes,
|
||||
"PBKDF2",
|
||||
false,
|
||||
["deriveBits"],
|
||||
)
|
||||
const hash = await crypto.subtle.deriveBits(params, keyMaterial, 256)
|
||||
const hashBase64 = jose.base64url.encode(new Uint8Array(hash))
|
||||
return hashBase64 === compare.hash
|
||||
},
|
||||
}
|
||||
}
|
||||
import { timingSafeEqual, randomBytes, scrypt } from "node:crypto"
|
||||
import { getRelativeUrl } from "@openauthjs/openauth/util"
|
||||
|
||||
export function ScryptHasher(opts?: {
|
||||
N?: number
|
||||
r?: number
|
||||
p?: number
|
||||
}): PasswordHasher<{
|
||||
hash: string
|
||||
salt: string
|
||||
N: number
|
||||
r: number
|
||||
p: number
|
||||
}> {
|
||||
const N = opts?.N ?? 16384
|
||||
const r = opts?.r ?? 8
|
||||
const p = opts?.p ?? 1
|
||||
|
||||
return {
|
||||
async hash(password) {
|
||||
const salt = randomBytes(16)
|
||||
const keyLength = 32 // 256 bits
|
||||
|
||||
const derivedKey = await new Promise<Buffer>((resolve, reject) => {
|
||||
scrypt(password, salt, keyLength, { N, r, p }, (err, derivedKey) => {
|
||||
if (err) reject(err)
|
||||
else resolve(derivedKey)
|
||||
})
|
||||
})
|
||||
|
||||
const hashBase64 = derivedKey.toString("base64")
|
||||
const saltBase64 = salt.toString("base64")
|
||||
|
||||
return {
|
||||
hash: hashBase64,
|
||||
salt: saltBase64,
|
||||
N,
|
||||
r,
|
||||
p,
|
||||
}
|
||||
},
|
||||
|
||||
async verify(password, compare) {
|
||||
const salt = Buffer.from(compare.salt, "base64")
|
||||
const keyLength = 32 // 256 bits
|
||||
|
||||
const derivedKey = await new Promise<Buffer>((resolve, reject) => {
|
||||
scrypt(
|
||||
password,
|
||||
salt,
|
||||
keyLength,
|
||||
{ N: compare.N, r: compare.r, p: compare.p },
|
||||
(err, derivedKey) => {
|
||||
if (err) reject(err)
|
||||
else resolve(derivedKey)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
return timingSafeEqual(derivedKey, Buffer.from(compare.hash, "base64"))
|
||||
},
|
||||
}
|
||||
}
|
||||
279
packages/functions/src/ui/base.tsx
Normal file
279
packages/functions/src/ui/base.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
/** @jsxImportSource hono/jsx */
|
||||
import { css } from "./css"
|
||||
import { type PropsWithChildren } from "hono/jsx"
|
||||
import { getTheme } from "@openauthjs/openauth/ui/theme"
|
||||
|
||||
export function Layout(
|
||||
props: PropsWithChildren<{
|
||||
size?: "small",
|
||||
page?: "root" | "password" | "popup"
|
||||
}>,
|
||||
) {
|
||||
const theme = getTheme()
|
||||
function get(key: "primary" | "background" | "logo", mode: "light" | "dark") {
|
||||
if (!theme) return
|
||||
if (!theme[key]) return
|
||||
if (typeof theme[key] === "string") return theme[key]
|
||||
|
||||
return theme[key][mode] as string | undefined
|
||||
}
|
||||
|
||||
const radius = (() => {
|
||||
if (theme?.radius === "none") return "0"
|
||||
if (theme?.radius === "sm") return "1"
|
||||
if (theme?.radius === "md") return "1.25"
|
||||
if (theme?.radius === "lg") return "1.5"
|
||||
if (theme?.radius === "full") return "1000000000001"
|
||||
return "1"
|
||||
})()
|
||||
|
||||
const script = "const DEFAULT_COLORS = ['#6A5ACD', '#E63525','#20B2AA', '#E87D58'];" +
|
||||
"const getModulo = (value, divisor, useEvenCheck) => {" +
|
||||
"const remainder = value % divisor;" +
|
||||
"if (useEvenCheck && Math.floor(value / Math.pow(10, useEvenCheck) % 10) % 2 === 0) {" +
|
||||
" return -remainder;" +
|
||||
" }" +
|
||||
" return remainder;" +
|
||||
" };" +
|
||||
"const generateColors = (name, 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)" +
|
||||
"}));" +
|
||||
"};" +
|
||||
"const generateFallbackAvatar = (text = 'wanjohi', size = 80, colors = DEFAULT_COLORS) => {" +
|
||||
" const colorData = generateColors(text, colors);" +
|
||||
" return '<svg viewBox=\"0 0 ' + size + ' ' + size + '\" fill=\"none\" role=\"img\" aria-describedby=\"' + text + '\" width=\"' + size + '\" height=\"' + size + '\">' +" +
|
||||
" '<title id=\"' + text + '\">Fallback avatar for ' + text + '</title>' +" +
|
||||
" '<mask id=\"mask__marble\" maskUnits=\"userSpaceOnUse\" x=\"0\" y=\"0\" width=\"' + size + '\" height=\"' + size + '\">' +" +
|
||||
" '<rect width=\"' + size + '\" height=\"' + size + '\" rx=\"' + (size * 2) + '\" fill=\"#FFFFFF\" />' +" +
|
||||
" '</mask>' +" +
|
||||
" '<g mask=\"url(#mask__marble)\">' +" +
|
||||
" '<rect width=\"' + size + '\" height=\"' + size + '\" 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 + ' ' + (size / 2) + ' ' + (size / 2) + ') 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 + ' ' + (size / 2) + ' ' + (size / 2) + ') scale(' + colorData[2].scale + ')\" />' +" +
|
||||
" '</g>' +" +
|
||||
" '<defs>' +" +
|
||||
" '<filter id=\"prefix__filter0_f\" filterUnits=\"userSpaceOnUse\" color-interpolation-filters=\"sRGB\">' +" +
|
||||
" '<feFlood flood-opacity=\"0\" result=\"BackgroundImageFix\" />' +" +
|
||||
" '<feBlend in=\"SourceGraphic\" in2=\"BackgroundImageFix\" result=\"shape\" />' +" +
|
||||
" '<feGaussianBlur stdDeviation=\"7\" result=\"effect1_foregroundBlur\" />' +" +
|
||||
" '</filter>' +" +
|
||||
" '</defs>' +" +
|
||||
" '</svg>';" +
|
||||
"};" +
|
||||
"const input = document.getElementById('username');" +
|
||||
"const avatarSpan = document.getElementById('username-icon');" +
|
||||
"input.addEventListener('input', (e) => {" +
|
||||
" avatarSpan.innerHTML = generateFallbackAvatar(e.target.value);" +
|
||||
"});";
|
||||
|
||||
const authWindowScript = `
|
||||
const openAuthWindow = async (provider) => {
|
||||
const POLL_INTERVAL = 300;
|
||||
const BASE_URL = window.location.origin;
|
||||
|
||||
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
|
||||
navigator.userAgent
|
||||
);
|
||||
|
||||
const createDesktopWindow = (authUrl) => {
|
||||
const config = {
|
||||
width: 700,
|
||||
height: 700,
|
||||
features: "toolbar=no,location=no,directories=no,status=no,menubar=no,scrollbars=no,resizable=no,copyhistory=no"
|
||||
};
|
||||
|
||||
const top = window.top.outerHeight / 2 + window.top.screenY - (config.height / 2);
|
||||
const left = window.top.outerWidth / 2 + window.top.screenX - (config.width / 2);
|
||||
|
||||
return window.open(
|
||||
authUrl,
|
||||
'Auth Popup',
|
||||
\`width=\${config.width},height=\${config.height},left=\${left},top=\${top},\${config.features}\`
|
||||
);
|
||||
};
|
||||
|
||||
const monitorAuthWindow = (targetWindow) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const handleAuthSuccess = (event) => {
|
||||
if (event.origin !== BASE_URL) return;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'auth_success') {
|
||||
cleanup();
|
||||
window.location.href = window.location.origin + "/" + provider + "/callback" + data.searchParams;
|
||||
resolve();
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore invalid JSON messages
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleAuthSuccess);
|
||||
|
||||
const timer = setInterval(() => {
|
||||
if (targetWindow.closed) {
|
||||
cleanup();
|
||||
reject(new Error('Authentication window was closed'));
|
||||
}
|
||||
}, POLL_INTERVAL);
|
||||
|
||||
function cleanup() {
|
||||
clearInterval(timer);
|
||||
window.removeEventListener('message', handleAuthSuccess);
|
||||
if (!targetWindow.closed) {
|
||||
targetWindow.location.href = 'about:blank'
|
||||
targetWindow.close();
|
||||
}
|
||||
window.focus();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const authUrl = \`\${BASE_URL}/\${provider}/authorize\`;
|
||||
const newWindow = isMobile ? window.open(authUrl, '_blank') : createDesktopWindow(authUrl);
|
||||
|
||||
if (!newWindow) {
|
||||
throw new Error('Failed to open authentication window');
|
||||
}
|
||||
|
||||
return monitorAuthWindow(newWindow);
|
||||
};
|
||||
|
||||
|
||||
const buttons = document.querySelectorAll('button[id^="button-"]');
|
||||
const formRoot = document.querySelector('[data-component="form-root"]');
|
||||
|
||||
const setLoadingState = (activeProvider) => {
|
||||
formRoot.setAttribute('data-disabled', 'true');
|
||||
|
||||
buttons.forEach(button => {
|
||||
button.style.pointerEvents = 'none';
|
||||
|
||||
const provider = button.id.replace('button-', '');
|
||||
if (provider === activeProvider) {
|
||||
button.setAttribute('data-loading', 'true');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const resetState = () => {
|
||||
formRoot.removeAttribute('data-disabled');
|
||||
|
||||
buttons.forEach(button => {
|
||||
button.style.pointerEvents = '';
|
||||
button.removeAttribute('data-loading');
|
||||
});
|
||||
};
|
||||
|
||||
buttons.forEach(button => {
|
||||
const provider = button.id.replace('button-', '');
|
||||
|
||||
if (provider === "password"){
|
||||
button.addEventListener('click', async (e) => {
|
||||
window.location.href = window.location.origin + "/" + provider + "/authorize";
|
||||
})
|
||||
} else {
|
||||
button.addEventListener('click', async (e) => {
|
||||
try {
|
||||
setLoadingState(provider);
|
||||
await openAuthWindow(provider);
|
||||
} catch (error) {
|
||||
resetState();
|
||||
console.error(\`Authentication failed for \${provider}:\`, error);
|
||||
}
|
||||
// finally {
|
||||
// resetState();
|
||||
// }
|
||||
});
|
||||
}
|
||||
});`;
|
||||
|
||||
const callbackScript = `
|
||||
if (window.opener == null) {
|
||||
window.location.href = "about:blank";
|
||||
}
|
||||
|
||||
const searchParams = window.location.search;
|
||||
|
||||
try {
|
||||
window.opener.postMessage(
|
||||
JSON.stringify({
|
||||
type: 'auth_success',
|
||||
searchParams: searchParams
|
||||
}),
|
||||
window.location.origin
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('Failed to send message to parent window:', e);
|
||||
}`;
|
||||
return (
|
||||
<html
|
||||
style={{
|
||||
"--color-background-light": get("background", "light"),
|
||||
"--color-background-dark": get("background", "dark"),
|
||||
"--color-primary-light": get("primary", "light"),
|
||||
"--color-primary-dark": get("primary", "dark"),
|
||||
"--font-family": theme?.font?.family,
|
||||
"--font-scale": theme?.font?.scale,
|
||||
"--border-radius": radius,
|
||||
backgroundColor: get("background", "dark"),
|
||||
}}
|
||||
>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>{theme?.title || "OpenAuthJS"}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href={theme?.favicon} />
|
||||
<style dangerouslySetInnerHTML={{ __html: css() }} />
|
||||
{theme?.css && (
|
||||
<style dangerouslySetInnerHTML={{ __html: theme.css }} />
|
||||
)}
|
||||
</head>
|
||||
<body>
|
||||
<div data-component="root">
|
||||
<main data-component="center" data-size={props.size}>
|
||||
{props.children}
|
||||
</main>
|
||||
<section data-component="logo-footer" >
|
||||
<svg viewBox="0 0 498.05 70.508" xmlns="http://www.w3.org/2000/svg" height={157} width={695} >
|
||||
<g stroke-linecap="round" fill-rule="evenodd" font-size="9pt" stroke="currentColor" stroke-width="0.25mm" fill="currentColor" style="stroke:currentColor;stroke-width:0.25mm;fill:currentColor">
|
||||
<path
|
||||
fill="url(#paint1)"
|
||||
pathLength="1"
|
||||
stroke="url(#paint1)"
|
||||
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>
|
||||
<defs>
|
||||
<linearGradient gradientUnits="userSpaceOnUse" id="paint1" x1="317.5" x2="314.007" y1="-51.5" y2="126">
|
||||
<stop stop-color="white"></stop>
|
||||
<stop offset="1" stop-opacity="0"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</section>
|
||||
</div>
|
||||
{props.page === "password" && (
|
||||
<script dangerouslySetInnerHTML={{ __html: script }} />
|
||||
)}
|
||||
{props.page === "root" && (
|
||||
<script dangerouslySetInnerHTML={{ __html: authWindowScript }} />
|
||||
)}
|
||||
{props.page === "popup" && (
|
||||
<script dangerouslySetInnerHTML={{ __html: callbackScript }} />
|
||||
)}
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
586
packages/functions/src/ui/css.ts
Normal file
586
packages/functions/src/ui/css.ts
Normal file
@@ -0,0 +1,586 @@
|
||||
export function css() {
|
||||
return `
|
||||
@import url("https://unpkg.com/tailwindcss@3.4.15/src/css/preflight.css");
|
||||
|
||||
:root {
|
||||
--color-background-dark: #0e0e11;
|
||||
--color-background-light: #ffffff;
|
||||
--color-primary-dark: #6772e5;
|
||||
--color-primary-light: #6772e5;
|
||||
--border-radius: 0;
|
||||
|
||||
--color-background: var(--color-background-dark);
|
||||
--color-primary: var(--color-primary-dark);
|
||||
|
||||
--spinner-size: 16px;
|
||||
--spinner-color: #FFF;
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
--color-background: var(--color-background-light);
|
||||
--color-primary: var(--color-primary-light);
|
||||
}
|
||||
|
||||
--color-high: oklch(
|
||||
from var(--color-background) clamp(0, calc((l - 0.714) * -1000), 1) 0 0
|
||||
);
|
||||
--color-low: oklch(from var(--color-background) clamp(0, calc((l - 0.714) * 1000), 1) 0 0);
|
||||
--lightness-high: color-mix(
|
||||
in oklch,
|
||||
var(--color-high) 0%,
|
||||
oklch(var(--color-high) 0 0)
|
||||
);
|
||||
--lightness-low: color-mix(
|
||||
in oklch,
|
||||
var(--color-low) 0%,
|
||||
oklch(var(--color-low) 0 0)
|
||||
);
|
||||
--font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji",
|
||||
"Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
--font-scale: 1;
|
||||
|
||||
--font-size-xs: calc(0.75rem * var(--font-scale));
|
||||
--font-size-sm: calc(0.875rem * var(--font-scale));
|
||||
--font-size-md: calc(1rem * var(--font-scale));
|
||||
--font-size-lg: calc(1.125rem * var(--font-scale));
|
||||
--font-size-xl: calc(1.25rem * var(--font-scale));
|
||||
--font-size-2xl: calc(1.5rem * var(--font-scale));
|
||||
}
|
||||
|
||||
html, html * {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
[data-component="root"] {
|
||||
font-family: var(--font-family);
|
||||
background-color: var(--color-background);
|
||||
padding: 1rem 1rem 0;
|
||||
color: white;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
user-select: none;
|
||||
color: var(--color-high);
|
||||
}
|
||||
|
||||
[data-component="logo-footer"] {
|
||||
position: fixed;
|
||||
bottom: -1px;
|
||||
font-size: 100%;
|
||||
max-width: 1440px;
|
||||
width: 100%;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 8px;
|
||||
z-index: 10;
|
||||
overflow: hidden;
|
||||
|
||||
& > svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: translateY(40%);
|
||||
opacity: 70%;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="popup"] {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
gap: 5px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 1.3rem;
|
||||
line-height: 1rem;
|
||||
font-weight: 500;
|
||||
|
||||
& [data-component="spinner"]{
|
||||
--spinner-size: 24px;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="center"] {
|
||||
max-width: 380px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
padding: 0 0 120px 0;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
|
||||
&[data-size="small"] {
|
||||
width: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="link"] {
|
||||
text-decoration: underline;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
[data-component="label"] {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-direction: column;
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
[data-component="input"] {
|
||||
width: 100%;
|
||||
height: 2.5rem;
|
||||
padding: 0 1rem;
|
||||
padding-left: 36px;
|
||||
border: 1px solid transparent;
|
||||
--background: oklch(
|
||||
from var(--color-background) calc(l + (-0.06 * clamp(0, calc((l - 0.714) * 1000), 1) + 0.03)) c h
|
||||
);
|
||||
background: var(--background);
|
||||
border-color: #343434;
|
||||
border-radius: calc(var(--border-radius) * 0.25rem);
|
||||
font-size: 0.875rem;
|
||||
outline: none;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px #161616,0 0 0 4px #707070
|
||||
}
|
||||
|
||||
&:user-invalid:focus {
|
||||
box-shadow: 0 0 0 2px #161616,0 0 0 4px #ff6369;
|
||||
}
|
||||
|
||||
&:user-invalid:not(:focus) {
|
||||
border-color: #ff6369;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
border-color: #e2e2e2;
|
||||
color: #171717;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px #fcfcfc,0 0 0 4px #8f8f8f;
|
||||
}
|
||||
|
||||
&:user-invalid:focus {
|
||||
box-shadow: 0 0 0 2px #fcfcfc, 0 0 0 4px #cd2b31;
|
||||
}
|
||||
|
||||
&:user-invalid:not(:focus) {
|
||||
border-color: #cd2b31;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="button"] {
|
||||
height: 2.5rem;
|
||||
cursor: pointer;
|
||||
margin-top: 3px;
|
||||
font-weight: 500;
|
||||
font-size: var(--font-size-sm);
|
||||
border-radius: calc(var(--border-radius) * 0.25rem);
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-primary);
|
||||
color: oklch(from var(--color-primary) clamp(0, calc((l - 0.714) * -1000), 1) 0 0);
|
||||
|
||||
&[data-color="ghost"] {
|
||||
background: transparent;
|
||||
color: var(--color-high);
|
||||
border: 1px solid
|
||||
oklch(
|
||||
from var(--color-background)
|
||||
calc(clamp(0.22, l + (-0.12 * clamp(0, calc((l - 0.714) * 1000), 1) + 0.06), 0.88)) c h
|
||||
);
|
||||
}
|
||||
|
||||
&:focus [data-component="spinner"]{
|
||||
display: block;
|
||||
}
|
||||
|
||||
[data-slot="icon"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="button-root"] {
|
||||
transition-property: border-color,background,color,transform,box-shadow;
|
||||
transition-duration: .15s;
|
||||
transition-timing-function: ease;
|
||||
height: 48px;
|
||||
cursor: pointer;
|
||||
padding: 0px 14px;
|
||||
margin-top: 3px;
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 2px solid #00000014;
|
||||
--spinner-color: #000;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
border: 2px solid #ffffff24;
|
||||
--spinner-color: #FFF;
|
||||
}
|
||||
|
||||
&[data-color="github"] {
|
||||
background: #24292e;
|
||||
color: #fff;
|
||||
border: 2px solid #1B1F22;
|
||||
&:hover {
|
||||
background: #434D56;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
border: 1px solid transparent;
|
||||
background: #434D56;
|
||||
&:hover {
|
||||
background: #24292e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&[data-color="discord"] {
|
||||
background: #4445e7;
|
||||
border: 2px solid #3836cc;
|
||||
color: #fff;
|
||||
&:hover {
|
||||
background: #5865F2;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
border: 1px solid transparent;
|
||||
background: #5865F2;
|
||||
&:hover {
|
||||
background: #4445e7;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background:rgb(229, 229, 229);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
&:hover {
|
||||
background:rgb(38, 38,38);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="icon"] {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="form"] {
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
[data-loading="true"]{
|
||||
& [data-component="spinner"]{
|
||||
display: block;
|
||||
}
|
||||
|
||||
& [data-slot="icon"] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-disabled="true"] {
|
||||
& button {
|
||||
background: rgb(229,229,229) !important;
|
||||
border: 2px solid #00000014 !important;
|
||||
opacity: .7 !important;
|
||||
color: inherit !important;
|
||||
cursor: not-allowed !important;
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background: rgb(38, 38,38) !important;
|
||||
border: 2px solid #ffffff24 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="form-root"] {
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
[data-component="form-header"] {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: start;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
color: #a0a0a0;
|
||||
max-width: 400px;
|
||||
font-weight: 400;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
color: #6f6f6f
|
||||
}
|
||||
|
||||
& > hr {
|
||||
border:0;
|
||||
background: #282828;
|
||||
height:2px;
|
||||
width:100%;
|
||||
margin-top:4px;
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
background: #e8e8e8
|
||||
}
|
||||
}
|
||||
|
||||
& > h1 {
|
||||
color: #ededed;
|
||||
font-weight:500;
|
||||
font-size: 1.25rem;
|
||||
letter-spacing:-.020625rem;
|
||||
line-height:1.5rem;
|
||||
margin:0;
|
||||
overflow-wrap:break-word;
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
color: #171717
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="form-header-root"] {
|
||||
color: #FFF;
|
||||
max-width: 400px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 2rem;
|
||||
line-height: 2.5rem;
|
||||
letter-spacing: -0.049375rem;
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
color: #000
|
||||
}
|
||||
|
||||
// & > hr {
|
||||
// border:0;
|
||||
// background: #282828;
|
||||
// height:2px;
|
||||
// width:100%;
|
||||
// margin-top:4px;
|
||||
|
||||
// @media (prefers-color-scheme: light) {
|
||||
// background: #e8e8e8
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
[data-component="input-container"] {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: start;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
color: #a0a0a0;
|
||||
max-width: 400px;
|
||||
font-weight: 400px;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
color: #6f6f6f
|
||||
}
|
||||
|
||||
& > small {
|
||||
color: #ff6369;
|
||||
display: block;
|
||||
line-height: 1rem;
|
||||
font-weight: 400;
|
||||
font-size: 0.75rem;
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
color: #cd2b31;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-error="true"] {
|
||||
& input {
|
||||
border-color: #ff6369;
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 2px #161616,0 0 0 4px #ff6369;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
border-color: #cd2b31;
|
||||
:focus {
|
||||
box-shadow: 0 0 0 2px #fcfcfc, 0 0 0 4px #cd2b31;
|
||||
border-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="input-wrapper"] {
|
||||
position: relative;
|
||||
width:100%;
|
||||
}
|
||||
|
||||
[data-component="input-icon"] {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
left: 8px;
|
||||
width: 20px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
|
||||
& > svg {
|
||||
width:20px;
|
||||
height:20px;
|
||||
display:block;
|
||||
max-width:100%;
|
||||
}
|
||||
}
|
||||
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:focus {
|
||||
transition: background-color 0s 600000s, color 0s 600000s !important;
|
||||
}
|
||||
|
||||
|
||||
[data-component="spinner"] {
|
||||
height: var(--spinner-size,20px);
|
||||
width: var(--spinner-size,20px);
|
||||
margin-left: calc(var(--spinner-size,20px)*-1px);
|
||||
display: none;
|
||||
|
||||
& > div {
|
||||
position: relative;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
height: var(--spinner-size,20px);
|
||||
width: var(--spinner-size,20px);
|
||||
}
|
||||
|
||||
& > div > div {
|
||||
animation: spin 1.2s linear infinite;
|
||||
background: var(--spinner-color);
|
||||
border-radius: 9999px;
|
||||
height: 8%;
|
||||
left: -10%;
|
||||
position: absolute;
|
||||
top: -3.9%;
|
||||
width: 24%;
|
||||
}
|
||||
|
||||
& > div > div:first-child {
|
||||
animation-delay: -1.2s;
|
||||
transform: rotate(.0001deg) translate(146%);
|
||||
}
|
||||
|
||||
& > div > div:nth-child(2) {
|
||||
animation-delay: -1.1s;
|
||||
transform: rotate(30deg) translate(146%);
|
||||
}
|
||||
|
||||
& > div > div:nth-child(3) {
|
||||
animation-delay: -1s;
|
||||
transform: rotate(60deg) translate(146%);
|
||||
}
|
||||
|
||||
& > div > div:nth-child(4) {
|
||||
animation-delay: -.9s;
|
||||
transform: rotate(90deg) translate(146%);
|
||||
}
|
||||
|
||||
& > div > div:nth-child(5) {
|
||||
animation-delay: -.8s;
|
||||
transform: rotate(120deg) translate(146%);
|
||||
}
|
||||
|
||||
& > div > div:nth-child(6) {
|
||||
animation-delay: -.7s;
|
||||
transform: rotate(150deg) translate(146%);
|
||||
}
|
||||
|
||||
& > div > div:nth-child(7) {
|
||||
animation-delay: -.6s;
|
||||
transform: rotate(180deg) translate(146%);
|
||||
}
|
||||
|
||||
& > div > div:nth-child(8) {
|
||||
animation-delay: -.5s;
|
||||
transform: rotate(210deg) translate(146%);
|
||||
}
|
||||
|
||||
& > div > div:nth-child(9) {
|
||||
animation-delay: -.4s;
|
||||
transform: rotate(240deg) translate(146%);
|
||||
}
|
||||
|
||||
& > div > div:nth-child(10) {
|
||||
animation-delay: -.3s;
|
||||
transform: rotate(270deg) translate(146%);
|
||||
}
|
||||
|
||||
& > div > div:nth-child(11) {
|
||||
animation-delay: -.2s;
|
||||
transform: rotate(300deg) translate(146%);
|
||||
}
|
||||
|
||||
& > div > div:nth-child(12) {
|
||||
animation-delay: -.1s;
|
||||
transform: rotate(330deg) translate(146%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: .15;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
`
|
||||
}
|
||||
481
packages/functions/src/ui/password.tsx
Normal file
481
packages/functions/src/ui/password.tsx
Normal file
@@ -0,0 +1,481 @@
|
||||
/** @jsxImportSource hono/jsx */
|
||||
import {
|
||||
type PasswordChangeError,
|
||||
type PasswordConfig,
|
||||
type PasswordLoginError,
|
||||
type PasswordRegisterError,
|
||||
} from "./adapters/password"
|
||||
// import { Layout } from "@openauthjs/openauth/ui/base"
|
||||
import { Layout } from "./base"
|
||||
import "@openauthjs/openauth/ui/form"
|
||||
// import { FormAlert } from "@openauthjs/openauth/ui/form"
|
||||
|
||||
const DEFAULT_COPY = {
|
||||
error_email_taken: "There is already an account with this email.",
|
||||
error_username_taken: "There is already an account with this username.",
|
||||
error_invalid_code: "Code is incorrect.",
|
||||
error_invalid_email: "Email is not valid.",
|
||||
error_invalid_password: "Password is incorrect.",
|
||||
error_invalid_username: "Username must only contain numbers and small letters.",
|
||||
error_password_mismatch: "Passwords do not match.",
|
||||
register_title: "Welcome to the app",
|
||||
register_description: "Sign in with your email",
|
||||
login_title: "Welcome to the app",
|
||||
login_description: "Sign in with your email",
|
||||
register: "Register",
|
||||
register_prompt: "Don't have an account?",
|
||||
login_prompt: "Already have an account?",
|
||||
login: "Login",
|
||||
change_prompt: "Forgot your password?",
|
||||
change: "Well that sucks",
|
||||
code_resend: "Resend code",
|
||||
code_return: "Back to",
|
||||
logo: "A",
|
||||
input_email: "john@doe.com",
|
||||
input_password: "●●●●●●●●●●●",
|
||||
input_code: "●●●●●●",
|
||||
input_username: "john",
|
||||
input_repeat: "●●●●●●●●●●●",
|
||||
button_continue: "Continue",
|
||||
} satisfies {
|
||||
[key in `error_${| PasswordLoginError["type"]
|
||||
| PasswordRegisterError["type"]
|
||||
| PasswordChangeError["type"]}`]: string
|
||||
} & Record<string, string>
|
||||
|
||||
export type PasswordUICopy = typeof DEFAULT_COPY
|
||||
|
||||
export interface PasswordUIOptions {
|
||||
sendCode: PasswordConfig["sendCode"]
|
||||
copy?: Partial<PasswordUICopy>
|
||||
}
|
||||
|
||||
export function PasswordUI(input: PasswordUIOptions) {
|
||||
const copy = {
|
||||
...DEFAULT_COPY,
|
||||
...input.copy,
|
||||
}
|
||||
return {
|
||||
sendCode: input.sendCode,
|
||||
login: async (_req, form, error): Promise<Response> => {
|
||||
const emailError = ["invalid_email", "email_taken"].includes(
|
||||
error?.type || "",
|
||||
)
|
||||
const passwordError = ["invalid_password", "password_mismatch"].includes(
|
||||
error?.type || "",
|
||||
)
|
||||
const jsx = (
|
||||
<Layout page="password">
|
||||
<div data-component="form-header">
|
||||
<h1>Login</h1>
|
||||
<span>
|
||||
{copy.register_prompt}{" "}
|
||||
<a data-component="link" href="register">
|
||||
{copy.register}
|
||||
</a>
|
||||
</span>
|
||||
<hr />
|
||||
</div>
|
||||
<form data-component="form" method="post">
|
||||
{/* <FormAlert message={error?.type && copy?.[`error_${error.type}`]} /> */}
|
||||
<div
|
||||
data-component="input-container"
|
||||
>
|
||||
<span>Email</span>
|
||||
<div data-error={emailError} data-component="input-wrapper">
|
||||
<span data-component="input-icon">
|
||||
<svg width="24px" height="24px" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" color="currentColor">
|
||||
<path d="M7 9l5 3.5L17 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
</path>
|
||||
<path d="M2 17V7a2 2 0 012-2h16a2 2 0 012 2v10a2 2 0 01-2 2H4a2 2 0 01-2-2z" stroke="currentColor" stroke-width="1.5">
|
||||
</path>
|
||||
</svg>
|
||||
</span>
|
||||
<input
|
||||
data-component="input"
|
||||
type="email"
|
||||
name="email"
|
||||
required
|
||||
placeholder={copy.input_email}
|
||||
autofocus={!error}
|
||||
value={form?.get("email")?.toString()}
|
||||
/>
|
||||
</div>
|
||||
<small>{error?.type && emailError && copy?.[`error_${error.type}`]}</small>
|
||||
</div>
|
||||
<div
|
||||
data-component="input-container"
|
||||
>
|
||||
<span>Password</span>
|
||||
<div data-error={passwordError} data-component="input-wrapper">
|
||||
<span data-component="input-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" color="currentColor"><path d="M12 16.5v-2m-7.732 4.345c.225 1.67 1.608 2.979 3.292 3.056c1.416.065 2.855.099 4.44.099s3.024-.034 4.44-.1c1.684-.076 3.067-1.385 3.292-3.055c.147-1.09.268-2.207.268-3.345s-.121-2.255-.268-3.345c-.225-1.67-1.608-2.979-3.292-3.056A95 95 0 0 0 12 9c-1.585 0-3.024.034-4.44.1c-1.684.076-3.067 1.385-3.292 3.055C4.12 13.245 4 14.362 4 15.5s.121 2.255.268 3.345" /><path d="M7.5 9V6.5a4.5 4.5 0 0 1 9 0V9" /></g></svg>
|
||||
</span>
|
||||
<input
|
||||
data-component="input"
|
||||
autofocus={error?.type === "invalid_password"}
|
||||
required
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder={copy.input_password}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<small>{error?.type && passwordError && copy?.[`error_${error.type}`]}</small>
|
||||
</div>
|
||||
<button data-component="button">
|
||||
<div data-component="spinner">
|
||||
<div>
|
||||
{new Array(12).fill(0).map((i, k) => (
|
||||
<div key={k} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{copy.button_continue}
|
||||
</button>
|
||||
<div style={{ padding: "2px 0" }} data-component="form-header">
|
||||
<hr />
|
||||
<span>
|
||||
{copy.change_prompt}{" "}
|
||||
<a data-component="link" href="change">
|
||||
{copy.change}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
</Layout>
|
||||
)
|
||||
return new Response(jsx.toString(), {
|
||||
status: error ? 401 : 200,
|
||||
headers: {
|
||||
"Content-Type": "text/html",
|
||||
},
|
||||
})
|
||||
},
|
||||
register: async (_req, state, form, error): Promise<Response> => {
|
||||
const emailError = ["invalid_email", "email_taken"].includes(
|
||||
error?.type || "",
|
||||
)
|
||||
const passwordError = ["invalid_password", "password_mismatch"].includes(
|
||||
error?.type || "",
|
||||
)
|
||||
|
||||
//Just in case the server does it
|
||||
const codeError = ["invalid_code"].includes(
|
||||
error?.type || "",
|
||||
)
|
||||
|
||||
const usernameError = ["invalid_username", "username_taken"].includes(
|
||||
error?.type || "",
|
||||
);
|
||||
|
||||
const jsx = (
|
||||
<Layout page="password">
|
||||
<div data-component="form-header">
|
||||
<h1>Register</h1>
|
||||
<span>
|
||||
{copy.login_prompt}{" "}
|
||||
<a data-component="link" href="authorize">
|
||||
{copy.login}
|
||||
</a>
|
||||
</span>
|
||||
<hr></hr>
|
||||
</div>
|
||||
<form data-component="form" method="post">
|
||||
{/* <FormAlert message={error?.type && copy?.[`error_${error.type}`]} /> */}
|
||||
{state.type === "start" && (
|
||||
<>
|
||||
<input type="hidden" name="action" value="register" />
|
||||
<div
|
||||
data-component="input-container"
|
||||
>
|
||||
<span>Email</span>
|
||||
<div data-error={emailError} data-component="input-wrapper">
|
||||
<span data-component="input-icon">
|
||||
<svg width="24px" height="24px" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" color="currentColor">
|
||||
<path d="M7 9l5 3.5L17 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
</path>
|
||||
<path d="M2 17V7a2 2 0 012-2h16a2 2 0 012 2v10a2 2 0 01-2 2H4a2 2 0 01-2-2z" stroke="currentColor" stroke-width="1.5">
|
||||
</path>
|
||||
</svg>
|
||||
</span>
|
||||
<input
|
||||
data-component="input"
|
||||
autofocus={!error || emailError}
|
||||
type="email"
|
||||
name="email"
|
||||
value={!emailError ? form?.get("email")?.toString() : ""}
|
||||
required
|
||||
placeholder={copy.input_email}
|
||||
/>
|
||||
</div>
|
||||
<small>{error?.type && emailError && copy?.[`error_${error.type}`]}</small>
|
||||
</div>
|
||||
<div
|
||||
data-component="input-container"
|
||||
>
|
||||
<span>Username</span>
|
||||
<div data-error={usernameError} data-component="input-wrapper">
|
||||
<span id="username-icon" data-component="input-icon">
|
||||
<svg width="24px" height="24px" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" color="currentColor"><path d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M4.271 18.346S6.5 15.5 12 15.5s7.73 2.846 7.73 2.846M12 12a3 3 0 100-6 3 3 0 000 6z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></svg>
|
||||
</span>
|
||||
<input
|
||||
id="username"
|
||||
data-component="input"
|
||||
autofocus={usernameError}
|
||||
type="text"
|
||||
name="username"
|
||||
placeholder={copy.input_username}
|
||||
required
|
||||
value={
|
||||
!usernameError ? form?.get("username")?.toString() : ""
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<small>{error?.type && usernameError && copy?.[`error_${error.type}`]}</small>
|
||||
</div>
|
||||
<div
|
||||
data-component="input-container"
|
||||
>
|
||||
<span>Password</span>
|
||||
<div data-error={passwordError} data-component="input-wrapper">
|
||||
<span data-component="input-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" color="currentColor"><path d="M12 16.5v-2m-7.732 4.345c.225 1.67 1.608 2.979 3.292 3.056c1.416.065 2.855.099 4.44.099s3.024-.034 4.44-.1c1.684-.076 3.067-1.385 3.292-3.055c.147-1.09.268-2.207.268-3.345s-.121-2.255-.268-3.345c-.225-1.67-1.608-2.979-3.292-3.056A95 95 0 0 0 12 9c-1.585 0-3.024.034-4.44.1c-1.684.076-3.067 1.385-3.292 3.055C4.12 13.245 4 14.362 4 15.5s.121 2.255.268 3.345" /><path d="M7.5 9V6.5a4.5 4.5 0 0 1 9 0V9" /></g></svg>
|
||||
</span>
|
||||
<input
|
||||
data-component="input"
|
||||
id="password"
|
||||
autofocus={passwordError}
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder={copy.input_password}
|
||||
required
|
||||
value={
|
||||
!passwordError ? form?.get("password")?.toString() : ""
|
||||
}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<small>{error?.type && passwordError && copy?.[`error_${error.type}`]}</small>
|
||||
</div>
|
||||
<button data-component="button">
|
||||
<div data-component="spinner">
|
||||
<div>
|
||||
{new Array(12).fill(0).map((i, k) => (
|
||||
<div key={k} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{copy.button_continue}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state.type === "code" && (
|
||||
<>
|
||||
<input type="hidden" name="action" value="verify" />
|
||||
<div
|
||||
data-component="input-container"
|
||||
>
|
||||
<span>Code</span>
|
||||
<div data-error={codeError} data-component="input-wrapper">
|
||||
<span data-component="input-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" stroke-width="1.5" viewBox="0 0 24 24"><path fill="currentColor" fill-rule="evenodd" d="M2.43 8.25a1 1 0 0 1 1-1h.952c1.063 0 1.952.853 1.952 1.938v6.562a1 1 0 1 1-2 0v-6.5H3.43a1 1 0 0 1-1-1m5.714 0a1 1 0 0 1 1-1h2.857c1.064 0 1.953.853 1.953 1.938v1.874A1.945 1.945 0 0 1 12 13h-1.857v1.75h2.81a1 1 0 1 1 0 2h-2.858a1.945 1.945 0 0 1-1.952-1.937v-1.876c0-1.084.889-1.937 1.952-1.937h1.858V9.25h-2.81a1 1 0 0 1-1-1m7.619 0a1 1 0 0 1 1-1h2.857c1.063 0 1.953.853 1.953 1.938v5.624a1.945 1.945 0 0 1-1.953 1.938h-2.857a1 1 0 1 1 0-2h2.81V13h-2.81a1 1 0 1 1 0-2h2.81V9.25h-2.81a1 1 0 0 1-1-1" clip-rule="evenodd" /></svg>
|
||||
</span>
|
||||
<input
|
||||
data-component="input"
|
||||
autofocus
|
||||
name="code"
|
||||
minLength={6}
|
||||
maxLength={6}
|
||||
required
|
||||
placeholder={copy.input_code}
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
</div>
|
||||
<small>{error?.type && codeError && copy?.[`error_${error.type}`]}</small>
|
||||
</div>
|
||||
<button data-component="button">
|
||||
<div data-component="spinner">
|
||||
<div>
|
||||
{new Array(12).fill(0).map((i, k) => (
|
||||
<div key={k} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{copy.button_continue}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</Layout>
|
||||
) as string
|
||||
return new Response(jsx.toString(), {
|
||||
headers: {
|
||||
"Content-Type": "text/html",
|
||||
},
|
||||
})
|
||||
},
|
||||
change: async (_req, state, form, error): Promise<Response> => {
|
||||
const passwordError = ["invalid_password", "password_mismatch"].includes(
|
||||
error?.type || "",
|
||||
)
|
||||
|
||||
const emailError = ["invalid_email", "email_taken"].includes(
|
||||
error?.type || "",
|
||||
)
|
||||
|
||||
const codeError = ["invalid_code"].includes(
|
||||
error?.type || "",
|
||||
)
|
||||
|
||||
const jsx = (
|
||||
<Layout page="password">
|
||||
<div data-component="form-header">
|
||||
<h1>Forgot Password</h1>
|
||||
{state.type != "update" && (
|
||||
<span>
|
||||
Suddenly had an epiphany?{" "}
|
||||
<a data-component="link" href="authorize">
|
||||
{copy.login}
|
||||
</a>
|
||||
</span>
|
||||
)}
|
||||
<hr />
|
||||
</div>
|
||||
<form data-component="form" method="post" replace>
|
||||
{/* <FormAlert message={error?.type && copy?.[`error_${error.type}`]} /> */}
|
||||
{state.type === "start" && (
|
||||
<>
|
||||
<input type="hidden" name="action" value="code" />
|
||||
<div
|
||||
data-component="input-container"
|
||||
>
|
||||
<span>Email</span>
|
||||
<div data-error={emailError} data-component="input-wrapper">
|
||||
<span data-component="input-icon">
|
||||
<svg width="24px" height="24px" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" color="currentColor">
|
||||
<path d="M7 9l5 3.5L17 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
</path>
|
||||
<path d="M2 17V7a2 2 0 012-2h16a2 2 0 012 2v10a2 2 0 01-2 2H4a2 2 0 01-2-2z" stroke="currentColor" stroke-width="1.5">
|
||||
</path>
|
||||
</svg>
|
||||
</span>
|
||||
<input
|
||||
data-component="input"
|
||||
autofocus
|
||||
type="email"
|
||||
name="email"
|
||||
required
|
||||
value={form?.get("email")?.toString()}
|
||||
placeholder={copy.input_email}
|
||||
/>
|
||||
</div>
|
||||
<small>{error?.type && emailError && copy?.[`error_${error.type}`]}</small>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{state.type === "code" && (
|
||||
<>
|
||||
<input type="hidden" name="action" value="verify" />
|
||||
<div
|
||||
data-component="input-container"
|
||||
>
|
||||
<span>Code</span>
|
||||
<div data-error={codeError} data-component="input-wrapper">
|
||||
<span data-component="input-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" stroke-width="1.5" viewBox="0 0 24 24"><path fill="currentColor" fill-rule="evenodd" d="M2.43 8.25a1 1 0 0 1 1-1h.952c1.063 0 1.952.853 1.952 1.938v6.562a1 1 0 1 1-2 0v-6.5H3.43a1 1 0 0 1-1-1m5.714 0a1 1 0 0 1 1-1h2.857c1.064 0 1.953.853 1.953 1.938v1.874A1.945 1.945 0 0 1 12 13h-1.857v1.75h2.81a1 1 0 1 1 0 2h-2.858a1.945 1.945 0 0 1-1.952-1.937v-1.876c0-1.084.889-1.937 1.952-1.937h1.858V9.25h-2.81a1 1 0 0 1-1-1m7.619 0a1 1 0 0 1 1-1h2.857c1.063 0 1.953.853 1.953 1.938v5.624a1.945 1.945 0 0 1-1.953 1.938h-2.857a1 1 0 1 1 0-2h2.81V13h-2.81a1 1 0 1 1 0-2h2.81V9.25h-2.81a1 1 0 0 1-1-1" clip-rule="evenodd" /></svg>
|
||||
</span>
|
||||
<input
|
||||
data-component="input"
|
||||
autofocus
|
||||
name="code"
|
||||
minLength={6}
|
||||
maxLength={6}
|
||||
required
|
||||
placeholder={copy.input_code}
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
</div>
|
||||
<small>{error?.type && codeError && copy?.[`error_${error.type}`]}</small>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{state.type === "update" && (
|
||||
<>
|
||||
<input type="hidden" name="action" value="update" />
|
||||
<div
|
||||
data-component="input-container"
|
||||
>
|
||||
<span>Password</span>
|
||||
<div data-error={passwordError} data-component="input-wrapper">
|
||||
<span data-component="input-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" color="currentColor"><path d="M12 16.5v-2m-7.732 4.345c.225 1.67 1.608 2.979 3.292 3.056c1.416.065 2.855.099 4.44.099s3.024-.034 4.44-.1c1.684-.076 3.067-1.385 3.292-3.055c.147-1.09.268-2.207.268-3.345s-.121-2.255-.268-3.345c-.225-1.67-1.608-2.979-3.292-3.056A95 95 0 0 0 12 9c-1.585 0-3.024.034-4.44.1c-1.684.076-3.067 1.385-3.292 3.055C4.12 13.245 4 14.362 4 15.5s.121 2.255.268 3.345" /><path d="M7.5 9V6.5a4.5 4.5 0 0 1 9 0V9" /></g></svg>
|
||||
</span>
|
||||
<input
|
||||
data-component="input"
|
||||
autofocus
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder={copy.input_password}
|
||||
required
|
||||
value={
|
||||
!passwordError ? form?.get("password")?.toString() : ""
|
||||
}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<small>{error?.type && passwordError && copy?.[`error_${error.type}`]}</small>
|
||||
</div>
|
||||
<div
|
||||
data-component="input-container"
|
||||
>
|
||||
<span>Confirm Password</span>
|
||||
<div data-error={passwordError} data-component="input-wrapper">
|
||||
<span data-component="input-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" color="currentColor"><path d="M12 16.5v-2m-7.732 4.345c.225 1.67 1.608 2.979 3.292 3.056c1.416.065 2.855.099 4.44.099s3.024-.034 4.44-.1c1.684-.076 3.067-1.385 3.292-3.055c.147-1.09.268-2.207.268-3.345s-.121-2.255-.268-3.345c-.225-1.67-1.608-2.979-3.292-3.056A95 95 0 0 0 12 9c-1.585 0-3.024.034-4.44.1c-1.684.076-3.067 1.385-3.292 3.055C4.12 13.245 4 14.362 4 15.5s.121 2.255.268 3.345" /><path d="M7.5 9V6.5a4.5 4.5 0 0 1 9 0V9" /></g></svg>
|
||||
</span>
|
||||
<input
|
||||
data-component="input"
|
||||
type="password"
|
||||
name="repeat"
|
||||
required
|
||||
value={
|
||||
!passwordError ? form?.get("password")?.toString() : ""
|
||||
}
|
||||
placeholder={copy.input_repeat}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<small>{error?.type && passwordError && copy?.[`error_${error.type}`]}</small>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<button data-component="button">
|
||||
<div data-component="spinner">
|
||||
<div>
|
||||
{new Array(12).fill(0).map((i, k) => (
|
||||
<div key={k} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{copy.button_continue}
|
||||
</button>
|
||||
</form>
|
||||
{state.type === "code" && (
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="code" />
|
||||
<input type="hidden" name="email" value={state.email} />
|
||||
</form>
|
||||
)}
|
||||
</Layout>
|
||||
)
|
||||
return new Response(jsx.toString(), {
|
||||
status: error ? 400 : 200,
|
||||
headers: {
|
||||
"Content-Type": "text/html",
|
||||
},
|
||||
})
|
||||
},
|
||||
} satisfies PasswordConfig
|
||||
}
|
||||
122
packages/functions/src/ui/select.tsx
Normal file
122
packages/functions/src/ui/select.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
/** @jsxImportSource hono/jsx */
|
||||
|
||||
import { Layout } from "./base"
|
||||
|
||||
export interface SelectProps {
|
||||
providers?: Record<
|
||||
string,
|
||||
{
|
||||
hide?: boolean
|
||||
display?: string
|
||||
}
|
||||
>
|
||||
}
|
||||
|
||||
export function Select(props?: SelectProps) {
|
||||
return async (
|
||||
providers: Record<string, string>,
|
||||
_req: Request,
|
||||
): Promise<Response> => {
|
||||
const jsx = (
|
||||
<Layout page="root">
|
||||
<div data-component="form-header-root">
|
||||
<h1>Welcome to Nestri</h1>
|
||||
</div>
|
||||
<div
|
||||
// data-disabled="true"
|
||||
data-component="form-root">
|
||||
{Object.entries(providers).map(([key, type]) => {
|
||||
const match = props?.providers?.[key]
|
||||
if (match?.hide) return
|
||||
const icon = ICON[key]
|
||||
return (
|
||||
<button
|
||||
id={`button-${key}`}
|
||||
data-component="button-root"
|
||||
// data-loading={key == "password" && "true"}
|
||||
data-color={key}
|
||||
>
|
||||
{icon && (
|
||||
<>
|
||||
<div data-component="spinner">
|
||||
<div>
|
||||
{new Array(12).fill(0).map((i, k) => (
|
||||
<div key={k} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<i data-slot="icon">{icon}</i>
|
||||
</>
|
||||
)}
|
||||
Continue with {match?.display || DISPLAY[type] || type}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
return new Response(jsx.toString(), {
|
||||
headers: {
|
||||
"Content-Type": "text/html",
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const DISPLAY: Record<string, string> = {
|
||||
twitch: "Twitch",
|
||||
google: "Google",
|
||||
github: "GitHub",
|
||||
discord: "Discord",
|
||||
password: "Password",
|
||||
}
|
||||
|
||||
const ICON: Record<string, any> = {
|
||||
code: (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
viewBox="0 0 52 52"
|
||||
data-name="Layer 1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8.55,36.91A6.55,6.55,0,1,1,2,43.45,6.54,6.54,0,0,1,8.55,36.91Zm17.45,0a6.55,6.55,0,1,1-6.55,6.54A6.55,6.55,0,0,1,26,36.91Zm17.45,0a6.55,6.55,0,1,1-6.54,6.54A6.54,6.54,0,0,1,43.45,36.91ZM8.55,19.45A6.55,6.55,0,1,1,2,26,6.55,6.55,0,0,1,8.55,19.45Zm17.45,0A6.55,6.55,0,1,1,19.45,26,6.56,6.56,0,0,1,26,19.45Zm17.45,0A6.55,6.55,0,1,1,36.91,26,6.55,6.55,0,0,1,43.45,19.45ZM8.55,2A6.55,6.55,0,1,1,2,8.55,6.54,6.54,0,0,1,8.55,2ZM26,2a6.55,6.55,0,1,1-6.55,6.55A6.55,6.55,0,0,1,26,2ZM43.45,2a6.55,6.55,0,1,1-6.54,6.55A6.55,6.55,0,0,1,43.45,2Z"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
password: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M18 16.663a3.5 3.5 0 0 1-1.373-1.163a3.5 3.5 0 0 1-.627-2a3.5 3.5 0 1 1 4.5 3.355V17l1.146 1.146a.5.5 0 0 1 0 .708L20.5 20l1.161 1.161a.5.5 0 0 1 .015.692l-1.823 1.984a.5.5 0 0 1-.722.015l-.985-.984a.5.5 0 0 1-.146-.354zM20.5 13a1 1 0 1 0-2 0a1 1 0 0 0 2 0M12 22.001c1.969 0 3.64-.354 5-1.069v-1.76c-1.223.883-2.88 1.33-5 1.33c-2.738 0-4.704-.747-5.957-2.214a2.25 2.25 0 0 1-.54-1.462v-.577a.75.75 0 0 1 .75-.75h9.215a4.5 4.5 0 0 1-.44-1.5H6.252a2.25 2.25 0 0 0-2.25 2.25v.578c0 .892.32 1.756.9 2.435c1.565 1.834 3.952 2.74 7.097 2.74m0-19.996a5 5 0 1 1 0 10a5 5 0 0 1 0-10m0 1.5a3.5 3.5 0 1 0 0 7a3.5 3.5 0 0 0 0-7" /></svg>
|
||||
// <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" color="currentColor"><path d="M12 16.5v-2m-7.732 4.345c.225 1.67 1.608 2.979 3.292 3.056c1.416.065 2.855.099 4.44.099s3.024-.034 4.44-.1c1.684-.076 3.067-1.385 3.292-3.055c.147-1.09.268-2.207.268-3.345s-.121-2.255-.268-3.345c-.225-1.67-1.608-2.979-3.292-3.056A95 95 0 0 0 12 9c-1.585 0-3.024.034-4.44.1c-1.684.076-3.067 1.385-3.292 3.055C4.12 13.245 4 14.362 4 15.5s.121 2.255.268 3.345" /><path d="M7.5 9V6.5a4.5 4.5 0 0 1 9 0V9" /></g></svg>
|
||||
),
|
||||
twitch: (
|
||||
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M40.1 32L10 108.9v314.3h107V480h60.2l56.8-56.8h87l117-117V32H40.1zm357.8 254.1L331 353H224l-56.8 56.8V353H76.9V72.1h321v214zM331 149v116.9h-40.1V149H331zm-107 0v116.9h-40.1V149H224z"
|
||||
></path>
|
||||
</svg>
|
||||
),
|
||||
google: (
|
||||
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 488 512">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M488 261.8C488 403.3 391.1 504 248 504 110.8 504 0 393.2 0 256S110.8 8 248 8c66.8 0 123 24.5 166.3 64.9l-67.5 64.9C258.5 52.6 94.3 116.6 94.3 256c0 86.5 69.1 156.6 153.7 156.6 98.2 0 135-70.4 140.8-106.9H248v-85.3h236.1c2.3 12.7 3.9 24.9 3.9 41.4z"
|
||||
></path>
|
||||
</svg>
|
||||
),
|
||||
github: (
|
||||
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"
|
||||
></path>
|
||||
</svg>
|
||||
),
|
||||
discord: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M20.317 4.492c-1.53-.69-3.17-1.2-4.885-1.49a.075.075 0 0 0-.079.036c-.21.369-.444.85-.608 1.23a18.566 18.566 0 0 0-5.487 0a12.36 12.36 0 0 0-.617-1.23A.077.077 0 0 0 8.562 3c-1.714.29-3.354.8-4.885 1.491a.07.07 0 0 0-.032.027C.533 9.093-.32 13.555.099 17.961a.08.08 0 0 0 .031.055a20.03 20.03 0 0 0 5.993 2.98a.078.078 0 0 0 .084-.026a13.83 13.83 0 0 0 1.226-1.963a.074.074 0 0 0-.041-.104a13.201 13.201 0 0 1-1.872-.878a.075.075 0 0 1-.008-.125c.126-.093.252-.19.372-.287a.075.075 0 0 1 .078-.01c3.927 1.764 8.18 1.764 12.061 0a.075.075 0 0 1 .079.009c.12.098.245.195.372.288a.075.075 0 0 1-.006.125c-.598.344-1.22.635-1.873.877a.075.075 0 0 0-.041.105c.36.687.772 1.341 1.225 1.962a.077.077 0 0 0 .084.028a19.963 19.963 0 0 0 6.002-2.981a.076.076 0 0 0 .032-.054c.5-5.094-.838-9.52-3.549-13.442a.06.06 0 0 0-.031-.028M8.02 15.278c-1.182 0-2.157-1.069-2.157-2.38c0-1.312.956-2.38 2.157-2.38c1.21 0 2.176 1.077 2.157 2.38c0 1.312-.956 2.38-2.157 2.38m7.975 0c-1.183 0-2.157-1.069-2.157-2.38c0-1.312.955-2.38 2.157-2.38c1.21 0 2.176 1.077 2.157 2.38c0 1.312-.946 2.38-2.157 2.38" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
75
packages/functions/src/utils.ts
Normal file
75
packages/functions/src/utils.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
export const handleGithub = async (accessKey: string) => {
|
||||
const headers = {
|
||||
Authorization: `token ${accessKey}`,
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
"User-Agent": "Nestri"
|
||||
};
|
||||
|
||||
try {
|
||||
const [emails, user] = await Promise.all([
|
||||
fetch("https://api.github.com/user/emails", { headers }).then(r => {
|
||||
if (!r.ok) throw new Error(`Failed to fetch emails: ${r.status}`);
|
||||
return r.json();
|
||||
}),
|
||||
fetch("https://api.github.com/user", { headers }).then(r => {
|
||||
if (!r.ok) throw new Error(`Failed to fetch user: ${r.status}`);
|
||||
return r.json();
|
||||
})
|
||||
]);
|
||||
|
||||
const primaryEmail = emails.find((email: { primary: boolean }) => email.primary);
|
||||
|
||||
if (!primaryEmail.verified) {
|
||||
throw new Error("Email not verified");
|
||||
}
|
||||
// console.log("raw user", user)
|
||||
|
||||
const { email, primary, verified } = primaryEmail;
|
||||
|
||||
return {
|
||||
primary: { email, primary, verified },
|
||||
avatar: user.avatar_url,
|
||||
username: user.name ?? user.login
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('GitHub OAuth error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const handleDiscord = async (accessKey: string) => {
|
||||
try {
|
||||
const response = await fetch("https://discord.com/api/v10/users/@me", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Discord API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const user = await response.json();
|
||||
// console.log("raw user", user)
|
||||
if (!user.verified) {
|
||||
throw new Error("Email not verified");
|
||||
}
|
||||
|
||||
return {
|
||||
primary: {
|
||||
email: user.email,
|
||||
verified: user.verified,
|
||||
primary: true
|
||||
},
|
||||
avatar: user.avatar
|
||||
? `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png`
|
||||
: null,
|
||||
username: user.global_name ?? user.username
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Discord OAuth error:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
}
|
||||
16
packages/functions/sst-env.d.ts
vendored
16
packages/functions/sst-env.d.ts
vendored
@@ -11,6 +11,22 @@ declare module "sst" {
|
||||
"type": "random.index/randomString.RandomString"
|
||||
"value": string
|
||||
}
|
||||
"DiscordClientID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"DiscordClientSecret": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GithubClientID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GithubClientSecret": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"InstantAdminToken": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
// Enable latest features
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"lib": [
|
||||
"ESNext",
|
||||
"DOM"
|
||||
],
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
}
|
||||
}
|
||||
}
|
||||
16
packages/input/sst-env.d.ts
vendored
16
packages/input/sst-env.d.ts
vendored
@@ -21,6 +21,22 @@ declare module "sst" {
|
||||
"CloudflareAuthKV": {
|
||||
"type": "sst.cloudflare.Kv"
|
||||
}
|
||||
"DiscordClientID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"DiscordClientSecret": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GithubClientID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GithubClientSecret": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"InstantAdminToken": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
|
||||
16
packages/moq/sst-env.d.ts
vendored
16
packages/moq/sst-env.d.ts
vendored
@@ -21,6 +21,22 @@ declare module "sst" {
|
||||
"CloudflareAuthKV": {
|
||||
"type": "sst.cloudflare.Kv"
|
||||
}
|
||||
"DiscordClientID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"DiscordClientSecret": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GithubClientID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GithubClientSecret": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"InstantAdminToken": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
|
||||
16
packages/ui/sst-env.d.ts
vendored
16
packages/ui/sst-env.d.ts
vendored
@@ -21,6 +21,22 @@ declare module "sst" {
|
||||
"CloudflareAuthKV": {
|
||||
"type": "sst.cloudflare.Kv"
|
||||
}
|
||||
"DiscordClientID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"DiscordClientSecret": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GithubClientID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GithubClientSecret": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"InstantAdminToken": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
|
||||
16
sst-env.d.ts
vendored
16
sst-env.d.ts
vendored
@@ -11,6 +11,22 @@ declare module "sst" {
|
||||
"type": "random.index/randomString.RandomString"
|
||||
"value": string
|
||||
}
|
||||
"DiscordClientID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"DiscordClientSecret": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GithubClientID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GithubClientSecret": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"InstantAdminToken": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
|
||||
Reference in New Issue
Block a user