Connection management now id based rather than type based

This commit is contained in:
Eclypsed
2024-01-07 14:23:22 -05:00
parent 0da467d1e0
commit 4a43b06c72
10 changed files with 166 additions and 164 deletions

View File

@@ -4,6 +4,7 @@ Big Problems:
- Authentication for every other potential streaming service: - Authentication for every other potential streaming service:
- YouTube Music:? https://ytmusicapi.readthedocs.io/en/stable/setup/oauth.html - YouTube Music:? https://ytmusicapi.readthedocs.io/en/stable/setup/oauth.html
- Spotify: https://developer.spotify.com/documentation/web-api/concepts/authorization - Spotify: https://developer.spotify.com/documentation/web-api/concepts/authorization
- Do I allow users to connect multiple accounts from the same service?
Little Problems: Little Problems:
- Video and audio need to be kept in sync, accounting for buffering and latency. - Video and audio need to be kept in sync, accounting for buffering and latency.

View File

@@ -0,0 +1,52 @@
{
"jellyfin": {
"displayName": "Jellyfin",
"type": ["streaming"],
"icon": "https://raw.githubusercontent.com/jellyfin/jellyfin-ux/55616553b692b1a6c7d8e786eeb7d8216e9b50df/branding/SVG/icon-transparent.svg"
},
"youtube-music": {
"displayName": "YouTube Music",
"type": ["streaming"],
"icon": "https://upload.wikimedia.org/wikipedia/commons/6/6a/Youtube_Music_icon.svg"
},
"spotify": {
"displayName": "Spotify",
"type": ["streaming"],
"icon": "https://upload.wikimedia.org/wikipedia/commons/8/84/Spotify_icon.svg"
},
"apple-music": {
"displayName": "Apple Music",
"type": ["streaming", "marketplace"],
"icon": "https://upload.wikimedia.org/wikipedia/commons/5/5f/Apple_Music_icon.svg"
},
"bandcamp": {
"displayName": "bandcamp",
"type": ["marketplace", "streaming"],
"icon": "https://upload.wikimedia.org/wikipedia/commons/6/6a/Bandcamp-button-bc-circle-aqua.svg"
},
"soundcloud": {
"displayName": "SoundCloud",
"type": ["streaming"],
"icon": "https://www.vectorlogo.zone/logos/soundcloud/soundcloud-icon.svg"
},
"lastfm": {
"displayName": "Last.fm",
"type": ["analytics"],
"icon": "https://upload.wikimedia.org/wikipedia/commons/c/c4/Lastfm.svg"
},
"plex": {
"displayName": "Plex",
"type": ["streaming"],
"icon": "https://www.vectorlogo.zone/logos/plextv/plextv-icon.svg"
},
"deezer": {
"displayName": "deezer",
"type": ["streaming"],
"icon": "https://upload.wikimedia.org/wikipedia/commons/6/6b/Deezer_Icon.svg"
},
"amazon-music": {
"displayName": "Amazon Music",
"type": ["streaming", "marketplace"],
"icon": "https://upload.wikimedia.org/wikipedia/commons/9/92/Amazon_Music_logo.svg"
}
}

Binary file not shown.

View File

@@ -5,10 +5,7 @@ const db = new Database('./src/lib/server/db/users.db', { verbose: console.info
db.pragma('foreign_keys = ON') db.pragma('foreign_keys = ON')
const initUsersTable = 'CREATE TABLE IF NOT EXISTS Users(id INTEGER PRIMARY KEY AUTOINCREMENT, username VARCHAR(64) UNIQUE NOT NULL, password VARCHAR(72) NOT NULL)' const initUsersTable = 'CREATE TABLE IF NOT EXISTS Users(id INTEGER PRIMARY KEY AUTOINCREMENT, username VARCHAR(64) UNIQUE NOT NULL, password VARCHAR(72) NOT NULL)'
const initUserConnectionsTable = const initUserConnectionsTable =
'CREATE TABLE IF NOT EXISTS UserConnections(id INTEGER PRIMARY KEY AUTOINCREMENT, userId INTEGER NOT NULL, serviceName VARCHAR(64) NOT NULL, accessToken TEXT, refreshToken TEXT, expiry DATETIME, FOREIGN KEY(userId) REFERENCES Users(id))' 'CREATE TABLE IF NOT EXISTS UserConnections(id INTEGER PRIMARY KEY AUTOINCREMENT, userId INTEGER NOT NULL, serviceType VARCHAR(64) NOT NULL, accessToken TEXT, refreshToken TEXT, expiry INTEGER, connectionInfo TEXT, FOREIGN KEY(userId) REFERENCES Users(id))'
const initJellyfinAuthTable = 'CREATE TABLE IF NOT EXISTS JellyfinConnections(id INTEGER PRIMARY KEY AUTOINCREMENT, user TEXT, accesstoken TEXT, serverid TEXT)'
const initYouTubeMusicConnectionsTable = ''
const initSpotifyConnectionsTable = ''
db.exec(initUsersTable) db.exec(initUsersTable)
db.exec(initUserConnectionsTable) db.exec(initUserConnectionsTable)
@@ -30,41 +27,29 @@ export class Users {
export class UserConnections { export class UserConnections {
static validServices = Object.keys(Services) static validServices = Object.keys(Services)
static getConnections = (userId, serviceNames = null) => { static getConnections = (userId) => {
if (!serviceNames) { const connections = db.prepare(`SELECT * FROM UserConnections WHERE userId = ?`).all(userId)
const connections = db.prepare('SELECT * FROM UserConnections WHERE userId = ?').all(userId)
if (connections.length === 0) return null
return connections
}
if (!Array.isArray(serviceNames)) {
if (typeof serviceNames !== 'string') throw new Error('Service names must be a string or array of strings')
serviceNames = [serviceNames]
}
serviceNames = serviceNames.filter((service) => this.validServices.includes(service))
const placeholders = serviceNames.map(() => '?').join(', ') // This is SQL-injection safe, the placeholders are just ?, ?, ?....
const connections = db.prepare(`SELECT * FROM UserConnections WHERE userId = ? AND serviceName IN (${placeholders})`).all(userId, ...serviceNames)
if (connections.length === 0) return null if (connections.length === 0) return null
return connections return connections
} }
// May want to give accessToken a default of null in the future if one of the services does not use access tokens // May want to give accessToken a default of null in the future if one of the services does not use access tokens
static setConnection = (userId, serviceName, accessToken, refreshToken = null, expiry = null) => { static addConnection = (userId, serviceType, accessToken, options = {}) => {
if (!this.validServices.includes(serviceName)) throw new Error(`Service name ${serviceName} is invalid`) const { refreshToken = null, expiry = null, connectionInfo = null } = options
const existingConnection = this.getConnections(userId, serviceName) if (!this.validServices.includes(serviceType)) throw new Error(`Service name ${serviceType} is invalid`)
if (existingConnection) {
db.prepare('UPDATE UserConnections SET accessToken = ?, refreshToken = ?, expiry = ? WHERE userId = ? AND serviceName = ?').run(accessToken, refreshToken, expiry, userId, serviceName) if (connectionInfo) JSON.parse(connectionInfo) // Aditional validation, if connectionInfo is not stringified valid json it will throw an error
} else {
db.prepare('INSERT INTO UserConnections(userId, serviceName, accessToken, refreshToken, expiry) VALUES(?, ?, ?, ?, ?)').run(userId, serviceName, accessToken, refreshToken, expiry) const commandInfo = db
} .prepare('INSERT INTO UserConnections(userId, serviceType, accessToken, refreshToken, expiry, connectionInfo) VALUES(?, ?, ?, ?, ?, ?)')
// return this.getConnections(userId, serviceName) <--- Uncomment this if want to return new connection data after update .run(userId, serviceType, accessToken, refreshToken, expiry, connectionInfo)
return commandInfo.lastInsertRowid
} }
static deleteConnection = (userId, serviceName) => { static deleteConnection = (userId, serviceId) => {
const info = db.prepare('DELETE FROM UserConnections WHERE userId = ? AND serviceName = ?').run(userId, serviceName) const commandInfo = db.prepare('DELETE FROM UserConnections WHERE userId = ? AND id = ?').run(userId, serviceId)
if (!info.changes === 0) throw new Error(`User does not have connection: ${serviceName}`) if (!commandInfo.changes === 0) throw new Error(`User does not have connection with id: ${serviceId}`)
return serviceId
} }
} }

View File

@@ -8,10 +8,5 @@
"displayName": "YouTube Music", "displayName": "YouTube Music",
"type": ["streaming"], "type": ["streaming"],
"icon": "https://upload.wikimedia.org/wikipedia/commons/6/6a/Youtube_Music_icon.svg" "icon": "https://upload.wikimedia.org/wikipedia/commons/6/6a/Youtube_Music_icon.svg"
},
"spotify": {
"displayName": "Spotify",
"type": ["streaming"],
"icon": "https://upload.wikimedia.org/wikipedia/commons/8/84/Spotify_icon.svg"
} }
} }

View File

@@ -29,7 +29,7 @@ export async function GET({ url, fetch }) {
if (!authResponse.headers.get('content-type').includes('application/json')) return new Response('Jellyfin server returned invalid data', { status: 500 }) if (!authResponse.headers.get('content-type').includes('application/json')) return new Response('Jellyfin server returned invalid data', { status: 500 })
const data = await authResponse.json() const data = await authResponse.json()
const requiredData = ['User', 'SessionInfo', 'AccessToken', 'ServerId'] const requiredData = ['User', 'AccessToken', 'ServerId']
if (!requiredData.every((key) => Object.keys(data).includes(key))) return new Response('Data missing from Jellyfin server response', { status: 500 }) if (!requiredData.every((key) => Object.keys(data).includes(key))) return new Response('Data missing from Jellyfin server response', { status: 500 })

View File

@@ -30,8 +30,11 @@ export async function PATCH({ request }) {
const schema = Joi.object({ const schema = Joi.object({
userId: Joi.number().required(), userId: Joi.number().required(),
connection: Joi.object({ connection: Joi.object({
serviceName: Joi.string().required(), serviceType: Joi.string().required(),
accessToken: Joi.string().required(), accessToken: Joi.string().required(),
refreshToken: Joi.string(),
expiry: Joi.number(),
connectionInfo: Joi.string(),
}).required(), }).required(),
}) })
@@ -41,9 +44,10 @@ export async function PATCH({ request }) {
const validation = schema.validate({ userId, connection }) const validation = schema.validate({ userId, connection })
if (validation.error) return new Response(validation.error.message, { status: 400 }) if (validation.error) return new Response(validation.error.message, { status: 400 })
UserConnections.setConnection(userId, connection.serviceName, connection.accessToken) const { serviceType, accessToken, refreshToken, expiry, connectionInfo } = connection
const newConnectionId = UserConnections.addConnection(userId, serviceType, accessToken, { refreshToken, expiry, connectionInfo })
return new Response('Updated Connection') return new Response(JSON.stringify({ id: newConnectionId }))
} }
/** @type {import('./$types').RequestHandler} */ /** @type {import('./$types').RequestHandler} */
@@ -51,7 +55,7 @@ export async function DELETE({ request }) {
const schema = Joi.object({ const schema = Joi.object({
userId: Joi.number().required(), userId: Joi.number().required(),
connection: Joi.object({ connection: Joi.object({
serviceName: Joi.string().required(), serviceId: Joi.string().required(),
}).required(), }).required(),
}) })
@@ -61,7 +65,7 @@ export async function DELETE({ request }) {
const validation = schema.validate({ userId, connection }) const validation = schema.validate({ userId, connection })
if (validation.error) return new Response(validation.error.message, { status: 400 }) if (validation.error) return new Response(validation.error.message, { status: 400 })
UserConnections.deleteConnection(userId, connection.serviceName) const deletedConnectionId = UserConnections.deleteConnection(userId, connection.serviceId)
return new Response('Deleted Connection') return new Response(JSON.stringify({ id: deletedConnectionId }))
} }

View File

@@ -10,11 +10,16 @@ export const load = async ({ fetch, locals }) => {
}, },
}) })
if (response.ok) { if (response.ok) {
const connectionsData = await response.json() const connectionData = await response.json()
if (connectionsData) { const clientConnectionData = {}
const serviceNames = connectionsData.map((connection) => connection.serviceName) connectionData?.forEach((connection) => {
return { existingConnections: serviceNames } const { id, serviceType, connectionInfo } = connection
clientConnectionData[id] = {
serviceType,
connectionInfo: JSON.parse(connectionInfo),
} }
})
return { existingConnections: clientConnectionData }
} else { } else {
const error = await response.text() const error = await response.text()
console.log(error) console.log(error)
@@ -42,23 +47,26 @@ export const actions = {
} }
const jellyfinAuthData = await jellyfinAuthResponse.json() const jellyfinAuthData = await jellyfinAuthResponse.json()
const jellyfinAccessToken = jellyfinAuthData.AccessToken const { User, AccessToken, ServerId } = jellyfinAuthData
const connectionInfo = JSON.stringify({ User, ServerId })
const updateConnectionsResponse = await fetch('/api/user/connections', { const updateConnectionsResponse = await fetch('/api/user/connections', {
method: 'PATCH', method: 'PATCH',
headers: { headers: {
apikey: SECRET_INTERNAL_API_KEY, apikey: SECRET_INTERNAL_API_KEY,
userId: locals.userId, userId: locals.userId,
}, },
body: JSON.stringify({ serviceName: 'jellyfin', accessToken: jellyfinAccessToken }), body: JSON.stringify({ serviceType: 'jellyfin', accessToken: AccessToken, connectionInfo }),
}) })
if (!updateConnectionsResponse.ok) return fail(500, { message: 'Internal Server Error' }) if (!updateConnectionsResponse.ok) return fail(500, { message: 'Internal Server Error' })
return { message: 'Updated Jellyfin connection' } const newConnectionData = await updateConnectionsResponse.json()
return { message: 'Added Jellyfin connection', newConnection: { id: newConnectionData.id, serviceType: 'jellyfin', connectionInfo: JSON.parse(connectionInfo) } }
}, },
deleteConnection: async ({ request, fetch, locals }) => { deleteConnection: async ({ request, fetch, locals }) => {
const formData = await request.formData() const formData = await request.formData()
const serviceName = formData.get('service') const serviceId = formData.get('serviceId')
const deleteConnectionResponse = await fetch('/api/user/connections', { const deleteConnectionResponse = await fetch('/api/user/connections', {
method: 'DELETE', method: 'DELETE',
@@ -66,11 +74,13 @@ export const actions = {
apikey: SECRET_INTERNAL_API_KEY, apikey: SECRET_INTERNAL_API_KEY,
userId: locals.userId, userId: locals.userId,
}, },
body: JSON.stringify({ serviceName }), body: JSON.stringify({ serviceId }),
}) })
if (!deleteConnectionResponse.ok) return fail(500, { message: 'Internal Server Error' }) if (!deleteConnectionResponse.ok) return fail(500, { message: 'Internal Server Error' })
return { message: 'Connection deleted' } const deletedConnectionData = await deleteConnectionResponse.json()
return { message: 'Connection deleted', deletedConnection: { id: deletedConnectionData.id } }
}, },
} }

View File

@@ -7,67 +7,10 @@
import { newestAlert } from '$lib/stores/alertStore.js' import { newestAlert } from '$lib/stores/alertStore.js'
import IconButton from '$lib/components/utility/iconButton.svelte' import IconButton from '$lib/components/utility/iconButton.svelte'
import Toggle from '$lib/components/utility/toggle.svelte' import Toggle from '$lib/components/utility/toggle.svelte'
import { onMount } from 'svelte'
export let data export let data
let existingConnections = data?.existingConnections let existingConnections = data.existingConnections
const testServices = {
jellyfin: {
displayName: 'Jellyfin',
type: ['streaming'],
icon: 'https://raw.githubusercontent.com/jellyfin/jellyfin-ux/55616553b692b1a6c7d8e786eeb7d8216e9b50df/branding/SVG/icon-transparent.svg',
},
'youtube-music': {
displayName: 'YouTube Music',
type: ['streaming'],
icon: 'https://upload.wikimedia.org/wikipedia/commons/6/6a/Youtube_Music_icon.svg',
},
spotify: {
displayName: 'Spotify',
type: ['streaming'],
icon: 'https://upload.wikimedia.org/wikipedia/commons/8/84/Spotify_icon.svg',
},
'apple-music': {
displayName: 'Apple Music',
type: ['streaming', 'marketplace'],
icon: 'https://upload.wikimedia.org/wikipedia/commons/5/5f/Apple_Music_icon.svg',
},
bandcamp: {
displayName: 'bandcamp',
type: ['marketplace', 'streaming'],
icon: 'https://upload.wikimedia.org/wikipedia/commons/6/6a/Bandcamp-button-bc-circle-aqua.svg',
},
soundcloud: {
displayName: 'SoundCloud',
type: ['streaming'],
icon: 'https://www.vectorlogo.zone/logos/soundcloud/soundcloud-icon.svg',
},
lastfm: {
displayName: 'Last.fm',
type: ['analytics'],
icon: 'https://upload.wikimedia.org/wikipedia/commons/c/c4/Lastfm.svg',
},
plex: {
displayName: 'Plex',
type: ['streaming'],
icon: 'https://www.vectorlogo.zone/logos/plextv/plextv-icon.svg',
},
deezer: {
displayName: 'deezer',
type: ['streaming'],
icon: 'https://upload.wikimedia.org/wikipedia/commons/6/6b/Deezer_Icon.svg',
},
'amazon-music': {
displayName: 'Amazon Music',
type: ['streaming', 'marketplace'],
icon: 'https://upload.wikimedia.org/wikipedia/commons/9/92/Amazon_Music_logo.svg',
},
}
const serviceAuthenticationMethods = {}
let formMode = null
const submitCredentials = ({ formData, action, cancel }) => { const submitCredentials = ({ formData, action, cancel }) => {
switch (action.search) { switch (action.search) {
case '?/authenticateJellyfin': case '?/authenticateJellyfin':
@@ -88,9 +31,6 @@
formData.append('deviceId', deviceId) formData.append('deviceId', deviceId)
break break
case '?/deleteConnection': case '?/deleteConnection':
const connection = formData.get('service')
$newestAlert = ['info', `Delete ${connection}`]
cancel()
break break
default: default:
cancel() cancel()
@@ -101,7 +41,19 @@
case 'failure': case 'failure':
return ($newestAlert = ['warning', result.data.message]) return ($newestAlert = ['warning', result.data.message])
case 'success': case 'success':
formMode = null modal = null
if (result.data?.newConnection) {
const { id, serviceType, connectionInfo } = result.data.newConnection
existingConnections[id] = {
serviceType,
connectionInfo,
}
existingConnections = existingConnections
} else if (result.data?.deletedConnection) {
const id = result.data.deletedConnection.id
delete existingConnections[id]
existingConnections = existingConnections
}
return ($newestAlert = ['success', result.data.message]) return ($newestAlert = ['success', result.data.message])
} }
} }
@@ -110,32 +62,39 @@
let modal let modal
</script> </script>
<main class="h-full"> <main>
<section class="mb-8 rounded-lg px-4" style="background-color: rgba(82, 82, 82, 0.25);"> <section class="mb-8 rounded-lg px-4" style="background-color: rgba(82, 82, 82, 0.25);">
<h1 class="py-2 text-xl">Add Connection</h1> <h1 class="py-2 text-xl">Add Connection</h1>
<div class="flex flex-wrap gap-2 pb-4"> <div class="flex flex-wrap gap-2 pb-4">
{#each Object.entries(testServices) as [serviceType, serviceData]} {#each Object.entries(Services) as [serviceType, serviceData]}
{#if !existingConnections.includes(serviceType)} <button
<button class="bg-ne h-14 rounded-md" style="background-image: linear-gradient(to bottom, rgb(30, 30, 30), rgb(10, 10, 10));" on:click={() => (modal = JellyfinAuthBox)}> class="bg-ne h-14 rounded-md"
style="background-image: linear-gradient(to bottom, rgb(30, 30, 30), rgb(10, 10, 10));"
on:click={() => {
if (serviceType === 'jellyfin') modal = JellyfinAuthBox
}}
>
<img src={serviceData.icon} alt="{serviceData.displayName} icon" class="aspect-square h-full p-2" /> <img src={serviceData.icon} alt="{serviceData.displayName} icon" class="aspect-square h-full p-2" />
</button> </button>
{/if}
{/each} {/each}
</div> </div>
</section> </section>
{#if existingConnections}
<div class="grid gap-8"> <div class="grid gap-8">
{#each existingConnections as connectionType} {#each Object.entries(existingConnections) as [connectionId, connectionData]}
{@const service = Services[connectionType]} {@const serviceData = Services[connectionData.serviceType]}
<section class="overflow-hidden rounded-lg" style="background-color: rgba(82, 82, 82, 0.25);" in:fly={{ duration: 300, x: 50 }}> <section class="overflow-hidden rounded-lg" style="background-color: rgba(82, 82, 82, 0.25);" transition:fly={{ x: 50 }}>
<header class="flex h-20 items-center gap-4 p-4"> <header class="flex h-20 items-center gap-4 p-4">
<img src={service.icon} alt="{service.displayName} icon" class="aspect-square h-full p-1" /> <img src={serviceData.icon} alt="{serviceData.displayName} icon" class="aspect-square h-full p-1" />
<div> <div>
{#if connectionData.serviceType === 'jellyfin'}
<div>{connectionData.connectionInfo.User.Name}</div>
{:else}
<div>Account Name</div> <div>Account Name</div>
<div class="text-sm text-neutral-500">{service.displayName}</div> {/if}
<div class="text-sm text-neutral-500">{serviceData.displayName}</div>
</div> </div>
<div class="ml-auto h-8"> <div class="ml-auto h-8">
<IconButton on:click={() => (modal = `delete-${connectionType}`)}> <IconButton on:click={() => (modal = `delete-${connectionId}`)}>
<i slot="icon" class="fa-solid fa-link-slash" /> <i slot="icon" class="fa-solid fa-link-slash" />
</IconButton> </IconButton>
</div> </div>
@@ -150,17 +109,16 @@
</section> </section>
{/each} {/each}
</div> </div>
{/if}
{#if modal} {#if modal}
<form method="post" use:enhance={submitCredentials} transition:fly={{ duration: 300, y: -15 }} class="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"> <form method="post" use:enhance={submitCredentials} transition:fly={{ y: -15 }} class="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
{#if typeof modal === 'string'} {#if typeof modal === 'string'}
{@const connectionType = modal.replace('delete-', '')} {@const connectionId = modal.replace('delete-', '')}
{@const service = Services[connectionType]} {@const service = Services[existingConnections[connectionId].serviceType]}
<div class="rounded-lg bg-neutral-900 p-5"> <div class="rounded-lg bg-neutral-900 p-5">
<h1 class="pb-4 text-center">Delete {service.displayName}?</h1> <h1 class="pb-4 text-center">Delete {service.displayName} connection?</h1>
<div class="flex w-60 justify-around"> <div class="flex w-60 justify-around">
<input type="hidden" name="service" value={connectionType} /> <input type="hidden" name="serviceId" value={connectionId} />
<button class="rounded bg-neutral-800 px-4 py-2 text-center" on:click={() => (modal = null)}>Cancel</button> <button class="rounded bg-neutral-800 px-4 py-2 text-center" on:click|preventDefault={() => (modal = null)}>Cancel</button>
<button class="rounded bg-red-500 px-4 py-2 text-center" formaction="?/deleteConnection">Delete</button> <button class="rounded bg-red-500 px-4 py-2 text-center" formaction="?/deleteConnection">Delete</button>
</div> </div>
</div> </div>

View File

@@ -2,23 +2,20 @@
import { createEventDispatcher } from 'svelte' import { createEventDispatcher } from 'svelte'
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const dispatchClose = () => {
dispatch('close')
}
</script> </script>
<div id="main-box" class="relative flex aspect-video w-screen max-w-2xl flex-col justify-center gap-9 rounded-xl bg-neutral-900 px-8"> <div id="main-box" class="relative flex aspect-video w-screen max-w-2xl flex-col justify-center gap-9 rounded-xl bg-neutral-900 px-8">
<h1 class="text-center text-4xl">Jellyfin Sign In</h1> <h1 class="text-center text-4xl">Jellyfin Sign In</h1>
<div class="flex w-full flex-col gap-5"> <div class="flex w-full flex-col gap-5">
<input type="text" name="serverUrl" autocomplete="off" placeholder="Server Url" class="border-jellyfin-blue h-10 w-full border-b-2 bg-transparent px-1 outline-none" /> <input type="text" name="serverUrl" autocomplete="off" placeholder="Server Url" class="h-10 w-full border-b-2 border-jellyfin-blue bg-transparent px-1 outline-none" />
<div class="flex w-full flex-row gap-4"> <div class="flex w-full flex-row gap-4">
<input type="text" name="username" autocomplete="off" placeholder="Username" class="border-jellyfin-blue h-10 w-full border-b-2 bg-transparent px-1 outline-none" /> <input type="text" name="username" autocomplete="off" placeholder="Username" class="h-10 w-full border-b-2 border-jellyfin-blue bg-transparent px-1 outline-none" />
<input type="password" name="password" placeholder="Password" class="border-jellyfin-blue h-10 w-full border-b-2 bg-transparent px-1 outline-none" /> <input type="password" name="password" placeholder="Password" class="h-10 w-full border-b-2 border-jellyfin-blue bg-transparent px-1 outline-none" />
</div> </div>
</div> </div>
<div class="flex items-center justify-around text-lg"> <div class="flex items-center justify-around text-lg">
<button id="cancel-button" type="button" class="w-1/3 rounded bg-neutral-800 py-2 transition-all active:scale-[97%]" on:click|preventDefault={dispatchClose}>Cancel</button> <button id="cancel-button" type="button" class="w-1/3 rounded bg-neutral-800 py-2 transition-all active:scale-[97%]" on:click|preventDefault={() => dispatch('close')}>Cancel</button>
<button id="submit-button" type="submit" class="bg-jellyfin-blue w-1/3 rounded py-2 transition-all active:scale-[97%]" formaction="?/authenticateJellyfin">Submit</button> <button id="submit-button" type="submit" class="w-1/3 rounded bg-jellyfin-blue py-2 transition-all active:scale-[97%]" formaction="?/authenticateJellyfin">Submit</button>
</div> </div>
</div> </div>