Skip to content

Commit

Permalink
Merge pull request #167 from radio4000/feat/admin-dashboard
Browse files Browse the repository at this point in the history
<r4-admin>
  • Loading branch information
oskarrough authored May 12, 2024
2 parents cf5581c + 65feaf2 commit 2c5055c
Show file tree
Hide file tree
Showing 3 changed files with 171 additions and 0 deletions.
18 changes: 18 additions & 0 deletions examples/r4-admin/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" href="data:;base64,iVBORw0KGgo=" />

<title>r4-admin</title>
<meta name="description" content="r4-admin" />
<script type="module" src="/src/index.js"></script>
</head>

<body>
<h1>r4-admin</h1>
<p>This page renders the r4-admin component. Note that the UI won't yet update after deleting rows.</p>
<r4-admin supabaseUrl="%VITE_SUPABASE_URL%" supabaseServiceRoleKey="%VITE_SUPABASE_SERVICE_ROLE_KEY%"></r4-admin>
</body>
</html>
2 changes: 2 additions & 0 deletions src/components/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* Except for R4Components, which is only used and imported on the demo/examples/ page.
*/
import R4Actions from './r4-actions.js'
import R4Admin from './r4-admin.js'
import R4App from './r4-app.js'
import R4AppMenu from './r4-app-menu.js'
import R4CommandMenu from './r4-command-menu.js'
Expand Down Expand Up @@ -52,6 +53,7 @@ import R4Icon from './r4-icon.js'

const componentDefinitions = {
'r4-actions': R4Actions,
'r4-admin': R4Admin,
'r4-app': R4App,
'r4-command-menu': R4CommandMenu,
'r4-app-menu': R4AppMenu,
Expand Down
151 changes: 151 additions & 0 deletions src/components/r4-admin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import {LitElement, html} from 'lit'
import {createClient} from '@supabase/supabase-js'
import {sdk} from '../libs/sdk'

/**
* To use, be sure to pass in the two supabase keys
* https://supabase.com/docs/reference/javascript/admin-api
*/
export default class R4Admin extends LitElement {
static properties = {
supabaseUrl: {type: String},
supabaseServiceRoleKey: {type: String},
result: {type: Object},
}

async connectedCallback() {
super.connectedCallback()
this.createClient()
if (this.supabase) this.prepareData()
}

/** Creates a Supabase client with full admin rights */
createClient() {
const url = this.supabaseUrl
const key = this.supabaseServiceRoleKey
if (!url || !key) return
// Use `this.supabase.auth.admin` for admin stuff.
this.supabase = createClient(url, key, {
auth: {
autoRefreshToken: false,
persistSession: false,
},
})
}

/** Sets this.result with all the data we want */
async prepareData() {
const {supabase} = this
const result = {users: [], orphanedChannels: [], orphanedTracks: []}

// Get users with nested channels.
const {data: allChannels} = await supabase.from('user_channel').select('channels(id,name,slug), user_id').limit(4000)
const {data: {users}} = await supabase.auth.admin.listUsers({perPage: 4000})
result.users = users
for (const user of result.users) {
user.channels = allChannels.filter(c => c.user_id === user.id).map(c => c.channels)
}

const {data: orphanedChannels} = await supabase.from('orphaned_channels').select('*')
result.orphanedChannels = orphanedChannels

const {data: orphanedTracks} = await supabase.from('orphaned_tracks').select('*')
result.orphanedTracks = orphanedTracks

console.log('result', result)
this.result = result
}

/** Deletes a Supabase auth.user AND any channels they are associated with
* @arg {string} id */
async deleteUser(id) {
const user = this.result.users.find((u) => u.id === id)

if (!window.confirm(`Really delete user and their data??\n${user.channels.map((c) => `${c.slug}n\n`)}`)) {
return
}

for (const c of user.channels) {
await this.deleteChannelAndTracks(c)
}

const {data, error} = await this.supabase.auth.admin.deleteUser(id)
console.log(`deleted auth.user`, id, data, error)
}

async deleteChannelAndTracks(channel) {
const res = await this.supabase.from('channels').delete().in('id', [channel.id])
// Deleting channel doesn't currently delete tracks, so we do it here.
// If a user deletes their account, tracks ARE deleted because of our Postgres RPC function.
const {data: tracks} = await sdk.channels.readChannelTracks(channel.slug)
await this.supabase
.from('tracks')
.delete()
.in('id', [tracks.map((t) => t.id)])
console.log(`deleted channel @${channel.slug} with ${tracks.length} tracks`, res)
}

isPotentiallySpam(user) {
const domains = [
'connectmailhub.com',
'rocketpostbox.com',
'clearmailhub.com',
'emailnestpro.com',
'inboxmasters.com',
'trustymailpro.com',
]
return domains.some((ltd) => user.email.includes(ltd))
}

render() {
const url = this.supabaseUrl
const key = this.supabaseServiceRoleKey
if (!url) return html`Missing supabase url`
if (!key) return html`Missing supabase service role key`
if (!this.result) return html`<r4-loading><r4-loading>`
return html`
<h2>${this.result.users?.length || 0} users</h2>
<ul>
${this.result.users?.map(
(user) => html`
<li>
${this.isPotentiallySpam(user) ? html`🍅` : null} ${user.email}
<button @click=${() => this.deleteUser(user.id)}>
Delete user and ${user.channels?.length} channels
</button>
<br />
<em>${user.id}</em>
<ul>
${user.channels?.map(
(x) => html`
<li>
<a href="https://radio4000.com/${x.slug}"> ${x.name} (@${x.slug})</a>
</li>
`,
)}
</ul>
<hr />
</li>
`,
)}
</ul>
<h2>Orphaned channels</h2>
<ul>
${this.result.orphanedChannels?.map(
(x) => html` <li>${x.name} <button @click=${() => {}}>delete</button></li> `,
)}
</ul>
<h2>Orphaned tracks</h2>
<ul>
${this.result.orphanedTracks?.map(
(x) => html` <li>${x.title} <button @click=${() => {}}>delete</button></li> `,
)}
</ul>
`
}

// Disable shadow DOM
createRenderRoot() {
return this
}
}

0 comments on commit 2c5055c

Please sign in to comment.