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:
Wanjohi
2025-01-07 23:58:27 +03:00
committed by GitHub
parent 56b877fa27
commit f6287ef586
28 changed files with 2639 additions and 28 deletions

View File

@@ -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

View File

@@ -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
View File

@@ -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

BIN
bun.lockb

Binary file not shown.

View File

@@ -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",

View File

@@ -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);

View File

@@ -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" }

View File

@@ -16,6 +16,6 @@
"zod-openapi": "^4.2.2"
},
"dependencies": {
"@instantdb/admin": "^0.17.3"
"@instantdb/admin": "^0.17.7"
}
}

View File

@@ -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",

View File

@@ -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,
});

View 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
}
};

View File

@@ -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

View File

@@ -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)

View 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",
},
})
}

View 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",
},
})
}

View 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
},
},
})
})
},
}
}

View 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"))
},
}
}

View 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>
)
}

View 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;
}
}
`
}

View 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
}

View 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>
)
}

View 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;
}
}

View File

@@ -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

View File

@@ -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
}
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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
View File

@@ -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