Merge branch 'master' of github.com:supabase/supabase
No files matched your search
@@ -0,0 +1,73 @@
|
||||
import { ReactElement, useState } from 'react'
|
||||
import Head from 'next/head'
|
||||
import NavBar from '../nav/NavBar'
|
||||
import SideBar from '../nav/SideBar'
|
||||
import Footer from '../Footer'
|
||||
|
||||
const DocsLayout = ({
|
||||
meta,
|
||||
children,
|
||||
toc,
|
||||
menuItems,
|
||||
currentPage,
|
||||
}: {
|
||||
meta: { title: string; description: string }
|
||||
children: string
|
||||
toc: any
|
||||
menuItems: any
|
||||
currentPage: string
|
||||
}) => {
|
||||
const theme = 'okaidia'
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{meta?.title} | Supabase</title>
|
||||
<meta name="description" content={meta?.description} />
|
||||
<meta content="width=device-width, initial-scale=1" name="viewport" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content={meta?.title} />
|
||||
<meta property="og:description" content={meta?.description} />
|
||||
<meta property="og:title" content={meta?.title} />
|
||||
<link
|
||||
rel="preload"
|
||||
href="https://unpkg.com/prismjs@0.0.1/themes/prism-okaidia.css"
|
||||
as="script"
|
||||
/>
|
||||
<link href={`https://unpkg.com/prismjs@0.0.1/themes/prism-${theme}.css`} rel="stylesheet" />
|
||||
</Head>
|
||||
|
||||
<main>
|
||||
<NavBar currentPage={currentPage} />
|
||||
<div className="flex w-full flex-row">
|
||||
<SideBar menuItems={menuItems} />
|
||||
<div className="docs-width flex w-full grid-cols-12 justify-between p-4 pb-8">
|
||||
<div className="w-full p-4">
|
||||
<article className="prose dark:prose-dark dark:bg-scale-200 width-full">
|
||||
{children}
|
||||
</article>
|
||||
</div>
|
||||
{toc && (
|
||||
<div className="prose border-scale-400 dark:bg-scale-200 thin-scrollbar table-of-contents-height w-1/4 border-l px-4">
|
||||
<h5>On this page</h5>
|
||||
<ul className="list-none pl-2 text-[0.8rem]">
|
||||
{toc.json.map((item: any, i: number) => {
|
||||
return (
|
||||
<li key={i}>
|
||||
<a href={`#${item.slug}`}>{item.content}</a>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default DocsLayout
|
||||
@@ -1,24 +1,16 @@
|
||||
import { ReactElement } from 'react'
|
||||
import Head from 'next/head'
|
||||
import NavBar from '../nav/NavBar'
|
||||
import SideBar from '../nav/SideBar'
|
||||
import Footer from '../Footer'
|
||||
|
||||
const DocsLayout = ({
|
||||
meta,
|
||||
const Layout = ({
|
||||
children,
|
||||
toc,
|
||||
menuItems,
|
||||
meta,
|
||||
currentPage,
|
||||
}: {
|
||||
meta: { title: string; description: string }
|
||||
children: ReactElement
|
||||
toc?: any
|
||||
menuItems: any
|
||||
children: React.ReactNode
|
||||
currentPage: string
|
||||
}) => {
|
||||
const theme = 'okaidia'
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
@@ -30,44 +22,16 @@ const DocsLayout = ({
|
||||
<meta property="og:site_name" content={meta?.title} />
|
||||
<meta property="og:description" content={meta?.description} />
|
||||
<meta property="og:title" content={meta?.title} />
|
||||
<link
|
||||
rel="preload"
|
||||
href="https://unpkg.com/prismjs@0.0.1/themes/prism-okaidia.css"
|
||||
as="script"
|
||||
/>
|
||||
<link href={`https://unpkg.com/prismjs@0.0.1/themes/prism-${theme}.css`} rel="stylesheet" />
|
||||
</Head>
|
||||
|
||||
<main>
|
||||
<NavBar currentPage={currentPage} />
|
||||
<div className="flex w-full flex-row">
|
||||
<SideBar menuItems={menuItems} />
|
||||
<div className="docs-width flex w-full grid-cols-12 justify-between p-4 pb-8">
|
||||
<div className="w-full p-4">
|
||||
<article className="prose dark:prose-dark dark:bg-scale-200 width-full">
|
||||
{children}
|
||||
</article>
|
||||
</div>
|
||||
{toc && (
|
||||
<div className="prose border-scale-400 dark:bg-scale-200 thin-scrollbar table-of-contents-height w-1/4 border-l px-4">
|
||||
<h5>On this page</h5>
|
||||
<ul className="list-none pl-2 text-[0.8rem]">
|
||||
{toc.json.map((item: any, i: number) => {
|
||||
return (
|
||||
<li key={i}>
|
||||
<a href={`#${item.slug}`}>{item.content}</a>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<article className="text-scale-1200 mx-4 md:mx-8 xl:mx-auto xl:max-w-7xl">
|
||||
{children}
|
||||
</article>
|
||||
<Footer />
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default DocsLayout
|
||||
export default Layout
|
||||
@@ -29,6 +29,7 @@ const NavBar = ({ currentPage }: { currentPage: string }) => {
|
||||
active: currentPage == 'Reference',
|
||||
link: '/reference/javascript/supabase-client',
|
||||
},
|
||||
{ text: 'Discussions', active: currentPage == 'Discussions', link: '/discussions' },
|
||||
]
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import Link from 'next/link'
|
||||
import { IconArrowLeft, IconArrowRight } from '@supabase/ui'
|
||||
|
||||
const pagination = ({ currentPage, totalCount }: { currentPage: number; totalCount: number }) => {
|
||||
// TODO: not sure if this is the most efficient way to do this. may need to refactor.
|
||||
const totalArray = Array.from({ length: totalCount }, (_, i: number) => i + 1)
|
||||
const pages = totalArray.filter((page: number) => {
|
||||
return page >= currentPage - 2 && page <= currentPage + 2
|
||||
})
|
||||
|
||||
return (
|
||||
<ul className="flex justify-center space-x-1 text-xs font-medium">
|
||||
<li>
|
||||
<Link href={`/discussions?page=${currentPage - 1}`}>
|
||||
<a className="border-scale-600 bg-scale-300 inline-flex h-8 w-8 items-center justify-center rounded border">
|
||||
<IconArrowLeft
|
||||
className="stroke-2 transition group-hover:-translate-x-1"
|
||||
height={12.5}
|
||||
/>
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
{pages.map((page: number, i: number) => {
|
||||
i = i + 1
|
||||
return (
|
||||
<li key={i}>
|
||||
<Link href={`/discussions?page=${page}`}>
|
||||
<a
|
||||
className={`border-scale-600 inline-flex h-8 w-8 items-center justify-center rounded border ${
|
||||
currentPage === page ? 'bg-brand-900' : 'bg-scale-300'
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
<li>
|
||||
<Link href={`/discussions?page=${currentPage + 1}`}>
|
||||
<a className="border-scale-600 bg-scale-300 inline-flex h-8 w-8 items-center justify-center rounded border">
|
||||
<IconArrowRight
|
||||
className="stroke-2 transition group-hover:-translate-x-1"
|
||||
height={12.5}
|
||||
/>
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
export default pagination
|
||||
@@ -2,4 +2,7 @@ const withTM = require('next-transpile-modules')(['common'])
|
||||
|
||||
module.exports = withTM({
|
||||
reactStrictMode: true,
|
||||
images: {
|
||||
domains: ['avatars.githubusercontent.com'],
|
||||
},
|
||||
})
|
||||
@@ -9,12 +9,14 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.6.6",
|
||||
"@mdx-js/loader": "^1.6.22",
|
||||
"@mdx-js/react": "^1.6.22",
|
||||
"@next/mdx": "^12.0.4",
|
||||
"@supabase/ui": "v0.37.0-alpha.50",
|
||||
"@tailwindcss/typography": "^0.4.1",
|
||||
"common": "*",
|
||||
"graphql": "^16.5.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"markdown-toc": "^1.2.0",
|
||||
"next": "12.1.0",
|
||||
@@ -23,6 +25,7 @@
|
||||
"react": "17.0.2",
|
||||
"react-copy-to-clipboard": "^5.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"react-markdown": "^8.0.3",
|
||||
"react-syntax-highlighter": "^15.3.1",
|
||||
"remark": "^14.0.2",
|
||||
"remark-html": "^15.0.0",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getAllDocs, getDocsBySlug } from '../lib/docs'
|
||||
import Layout from '../components/layouts/Layout'
|
||||
import Layout from '../components/layouts/DocsLayout'
|
||||
import { serialize } from 'next-mdx-remote/serialize'
|
||||
import { MDXRemote } from 'next-mdx-remote'
|
||||
import { MDXProvider } from '@mdx-js/react'
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import { ApolloClient, createHttpLink, InMemoryCache, gql } from '@apollo/client'
|
||||
import { setContext } from '@apollo/client/link/context'
|
||||
import Layout from '../../components/layouts/Layout'
|
||||
import { IconArrowLeft, Badge, Button, IconArrowUp, IconCheck } from '@supabase/ui'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
|
||||
export async function getServerSideProps(context: any) {
|
||||
const prams = context.params
|
||||
|
||||
const httpLink = createHttpLink({
|
||||
uri: 'https://api.github.com/graphql',
|
||||
})
|
||||
|
||||
const authLink = setContext((_, { Headers }) => {
|
||||
return {
|
||||
headers: {
|
||||
Headers,
|
||||
authorization: `Bearer ${process.env.GITHUB_ACCESS_TOKEN}`,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const client = new ApolloClient({
|
||||
link: authLink.concat(httpLink),
|
||||
cache: new InMemoryCache(),
|
||||
})
|
||||
|
||||
const { data } = await client.query({
|
||||
query: gql`
|
||||
{
|
||||
repository(owner: "supabase", name: "supabase") {
|
||||
discussion(number: ${prams!.id}) {
|
||||
id
|
||||
number
|
||||
title
|
||||
upvoteCount
|
||||
body
|
||||
createdAt
|
||||
answer {
|
||||
isAnswer
|
||||
}
|
||||
author {
|
||||
url
|
||||
login
|
||||
avatarUrl
|
||||
}
|
||||
category {
|
||||
name
|
||||
}
|
||||
comments(first: 10) {
|
||||
totalCount
|
||||
nodes {
|
||||
id
|
||||
isAnswer
|
||||
replies(first: 10) {
|
||||
nodes {
|
||||
author {
|
||||
avatarUrl
|
||||
login
|
||||
url
|
||||
}
|
||||
body
|
||||
createdAt
|
||||
}
|
||||
totalCount
|
||||
}
|
||||
author {
|
||||
avatarUrl
|
||||
login
|
||||
url
|
||||
}
|
||||
body
|
||||
createdAt
|
||||
upvoteCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
|
||||
return {
|
||||
props: {
|
||||
data,
|
||||
prams,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const discussion = ({ data, prams }: any) => {
|
||||
const meta_description = 'Discuss Supabase with other developers and users.'
|
||||
|
||||
const getTimeSinceCreated = (createdAt: string) => {
|
||||
const date = new Date(createdAt)
|
||||
const now = new Date()
|
||||
const timeSinceCreated = now.getTime() - date.getTime()
|
||||
const days = Math.floor(timeSinceCreated / (1000 * 60 * 60 * 24))
|
||||
const hours = Math.floor((timeSinceCreated % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
|
||||
const minutes = Math.floor((timeSinceCreated % (1000 * 60 * 60)) / (1000 * 60))
|
||||
const seconds = Math.floor((timeSinceCreated % (1000 * 60)) / 1000)
|
||||
|
||||
if (days < 7) {
|
||||
if (days > 1) {
|
||||
return `${days} days ago`
|
||||
} else {
|
||||
if (hours > 1) {
|
||||
return `${hours} hours ago`
|
||||
} else {
|
||||
if (minutes > 1) {
|
||||
return `${minutes} minutes ago`
|
||||
} else {
|
||||
return `${seconds} seconds ago`
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout
|
||||
meta={{
|
||||
title: data.repository.discussion.title,
|
||||
description: meta_description,
|
||||
}}
|
||||
currentPage={'Discussions'}
|
||||
>
|
||||
<div className="my-8 space-y-8">
|
||||
<div>
|
||||
<div className="flex justify-between">
|
||||
<Link href="/discussions">
|
||||
<a className="text-scale-1100 group flex w-fit items-center text-sm">
|
||||
<IconArrowLeft
|
||||
className="stroke-2 transition group-hover:-translate-x-1"
|
||||
height={15}
|
||||
/>
|
||||
Go Back
|
||||
</a>
|
||||
</Link>
|
||||
<Link href={`https://github.com/supabase/supabase/discussions/${prams.id}`}>
|
||||
<a target="_blank">
|
||||
<Button className="text-base text-white">Open in Github</Button>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center text-3xl">
|
||||
<span>{data.repository.discussion.title}</span>
|
||||
<span className="text-scale-1000 ml-1">#{data.repository.discussion.number}</span>
|
||||
</div>
|
||||
<div className="text-scale-1100 mt-1 flex items-center space-x-2">
|
||||
<Badge color={data.repository.discussion.answer?.isAnswer ? 'green' : 'gray'}>
|
||||
{data.repository.discussion.answer?.isAnswer ? 'Answered' : 'Unanswered'}
|
||||
</Badge>
|
||||
<span>
|
||||
<Link href={data.repository.discussion.author.url}>
|
||||
<a target="_blank">{data.repository.discussion.author.login}</a>
|
||||
</Link>
|
||||
{` started this conversation in ${data.repository.discussion.category.name}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-scale-400 border-scale-600 space-y-4 rounded-lg border px-6 py-4">
|
||||
<div className="flex items-center">
|
||||
<Link href={data.repository.discussion.author.url}>
|
||||
<a target="_blank" className="flex items-center">
|
||||
<Image
|
||||
className="rounded-full"
|
||||
src={data.repository.discussion.author.avatarUrl}
|
||||
width={30}
|
||||
height={30}
|
||||
/>
|
||||
<span className="ml-2">{data.repository.discussion.author.login}</span>
|
||||
</a>
|
||||
</Link>
|
||||
<span className="text-scale-1100 ml-2">{`on ${getTimeSinceCreated(
|
||||
data.repository.discussion.createdAt
|
||||
)}`}</span>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<ReactMarkdown>{data.repository.discussion.body}</ReactMarkdown>
|
||||
</div>
|
||||
<div className="item-center bg-scale-400 border-scale-600 flex h-fit w-fit items-center space-x-1 rounded-xl border py-1 pl-1 pr-2">
|
||||
<IconArrowUp className="stroke-2" height={10} />
|
||||
<span className="text-sm">{data.repository.discussion.upvoteCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>{`${data.repository.discussion.comments.totalCount} comments`}</div>
|
||||
<div className="space-y-8">
|
||||
{data.repository.discussion.comments.nodes.map((comment: any) => {
|
||||
return (
|
||||
<div key={comment.id}>
|
||||
<div
|
||||
className={`bg-scale-400 space-y-4 border px-6 py-4 ${
|
||||
comment.replies.totalCount > 0 ? 'rounded-t-lg' : 'rounded-lg'
|
||||
} ${comment.isAnswer === true ? 'border-brand-900' : 'border-scale-600'}`}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Link href={comment.author.url}>
|
||||
<a target="_blank" className="flex items-center">
|
||||
<Image
|
||||
className="rounded-full"
|
||||
src={comment.author.avatarUrl}
|
||||
width={30}
|
||||
height={30}
|
||||
/>
|
||||
<span className="ml-2">{comment.author.login}</span>
|
||||
</a>
|
||||
</Link>
|
||||
<span className="text-scale-1100 ml-2">{`on ${getTimeSinceCreated(
|
||||
comment.createdAt
|
||||
)}`}</span>
|
||||
</div>
|
||||
<div>
|
||||
<ReactMarkdown>{comment.body}</ReactMarkdown>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{comment.isAnswer === true && (
|
||||
<div className="bg-brand-900 rounded-full px-1.5 py-2 text-white">
|
||||
<IconCheck className="stroke-2" height={15} />
|
||||
</div>
|
||||
)}
|
||||
<div className="item-center bg-scale-400 border-scale-600 flex h-fit items-center space-x-1 rounded-xl border py-1 pl-1 pr-2">
|
||||
<IconArrowUp className="stroke-2" height={10} />
|
||||
<span className="text-sm">{comment.upvoteCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{comment.replies.totalCount > 0 && (
|
||||
<div className="border-scale-600 bg-scale-100 space-y-4 rounded-b-lg border p-4">
|
||||
{comment.replies.nodes.map((reply: any, i: number) => {
|
||||
const lastReply = i === comment.replies.nodes.length - 1
|
||||
return (
|
||||
<div
|
||||
className={`space-y-4 pb-4 ${
|
||||
lastReply === true ? null : 'border-scale-600 border-b'
|
||||
}`}
|
||||
key={i}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Link href={reply.author.url}>
|
||||
<a target="_blank" className="flex items-center">
|
||||
<Image
|
||||
className="rounded-full"
|
||||
src={reply.author.avatarUrl}
|
||||
width={30}
|
||||
height={30}
|
||||
/>
|
||||
<span className="ml-2">{reply.author.login}</span>
|
||||
</a>
|
||||
</Link>
|
||||
<span className="text-scale-1100 ml-2">{`on ${getTimeSinceCreated(
|
||||
reply.createdAt
|
||||
)}`}</span>
|
||||
</div>
|
||||
<div>
|
||||
<ReactMarkdown>{reply.body}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<Link href={`https://github.com/supabase/supabase/discussions/${prams.id}`}>
|
||||
<a target="_blank">
|
||||
<Button className="text-base text-white">comment on Github</Button>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default discussion
|
||||
@@ -0,0 +1,273 @@
|
||||
import { ApolloClient, createHttpLink, InMemoryCache, gql } from '@apollo/client'
|
||||
import { setContext } from '@apollo/client/link/context'
|
||||
import Layout from '../../components/layouts/Layout'
|
||||
import { IconArrowUp, IconCheckCircle } from '@supabase/ui'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import Pagination from '../../components/pagination'
|
||||
|
||||
export async function getServerSideProps(context: { query: { page: number } }) {
|
||||
const Page: any = context.query?.page || 1
|
||||
const page: number = parseInt(Page)
|
||||
|
||||
const httpLink = createHttpLink({
|
||||
uri: 'https://api.github.com/graphql',
|
||||
})
|
||||
|
||||
const authLink = setContext((_, { Headers }) => {
|
||||
return {
|
||||
headers: {
|
||||
Headers,
|
||||
authorization: `Bearer ${process.env.GITHUB_ACCESS_TOKEN}`,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const client = new ApolloClient({
|
||||
link: authLink.concat(httpLink),
|
||||
cache: new InMemoryCache(),
|
||||
})
|
||||
|
||||
const { data } = await client.query({
|
||||
query: gql`
|
||||
{
|
||||
repository(owner: "supabase", name: "supabase") {
|
||||
pinnedDiscussions(first: 3) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
discussion {
|
||||
id
|
||||
number
|
||||
title
|
||||
author {
|
||||
url
|
||||
avatarUrl
|
||||
login
|
||||
}
|
||||
category {
|
||||
id
|
||||
emoji
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
totalCount
|
||||
}
|
||||
discussions(first: ${page}0) {
|
||||
nodes {
|
||||
id
|
||||
number
|
||||
createdAt
|
||||
upvoteCount
|
||||
title
|
||||
body
|
||||
comments(first: 3) {
|
||||
totalCount
|
||||
nodes {
|
||||
id
|
||||
author {
|
||||
avatarUrl
|
||||
login
|
||||
}
|
||||
}
|
||||
}
|
||||
category {
|
||||
id
|
||||
name
|
||||
emoji
|
||||
}
|
||||
author {
|
||||
login
|
||||
url
|
||||
avatarUrl
|
||||
}
|
||||
answer {
|
||||
isAnswer
|
||||
author {
|
||||
avatarUrl
|
||||
login
|
||||
}
|
||||
}
|
||||
}
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
|
||||
return {
|
||||
props: {
|
||||
data,
|
||||
page,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const discussions = ({ data, page }: { data: any; page: number }) => {
|
||||
const meta_title = 'Discussions'
|
||||
const meta_description = 'Discuss Supabase with other developers and users.'
|
||||
|
||||
// TODO: This was a quick hack to show emojis in discussions. should be replaced with a proper solution.
|
||||
const emojiInput = [':zap:', ':ship:', ':bulb:', ':question:', ':raised_hands:']
|
||||
const emojiOutput = ['⚡️', '🚢', '💡', '❓', '🙌']
|
||||
|
||||
const getEmoji = (category: string) => {
|
||||
const index = emojiInput.indexOf(category)
|
||||
return emojiOutput[index]
|
||||
}
|
||||
|
||||
const getTimeSinceCreated = (createdAt: string) => {
|
||||
const date = new Date(createdAt)
|
||||
const now = new Date()
|
||||
const timeSinceCreated = now.getTime() - date.getTime()
|
||||
const days = Math.floor(timeSinceCreated / (1000 * 60 * 60 * 24))
|
||||
const hours = Math.floor((timeSinceCreated % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
|
||||
const minutes = Math.floor((timeSinceCreated % (1000 * 60 * 60)) / (1000 * 60))
|
||||
const seconds = Math.floor((timeSinceCreated % (1000 * 60)) / 1000)
|
||||
|
||||
if (days < 7) {
|
||||
if (days > 1) {
|
||||
return `${days} days ago`
|
||||
} else {
|
||||
if (hours > 1) {
|
||||
return `${hours} hours ago`
|
||||
} else {
|
||||
if (minutes > 1) {
|
||||
return `${minutes} minutes ago`
|
||||
} else {
|
||||
return `${seconds} seconds ago`
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout meta={{ title: meta_title, description: meta_description }} currentPage={'Discussions'}>
|
||||
<div className="mb-16 space-y-8">
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<h2 className="text-3xl md:text-4xl">Discussions</h2>
|
||||
<Link href="https://github.com/supabase/supabase/discussions/new">
|
||||
<a
|
||||
target="_blank"
|
||||
className="rounded-lg bg-[#34b17b] px-4 py-2 text-xs text-white transition duration-150 hover:bg-[#40cf8e] md:text-sm"
|
||||
>
|
||||
New Discussion
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="justify-around space-y-4 md:flex md:space-y-0 md:space-x-4">
|
||||
{data.repository.pinnedDiscussions.edges.map((discussion: any) => {
|
||||
const item = discussion.node
|
||||
return (
|
||||
<div
|
||||
className="border-scale-600 bg-scale-400 rounded-xl border px-4 py-4 md:w-1/3"
|
||||
key={item.id}
|
||||
>
|
||||
<Link href={`/discussions/${item.discussion.number}`}>
|
||||
<a className="space-y-4">
|
||||
<div className="flex justify-end text-2xl">
|
||||
{getEmoji(item.discussion.category.emoji)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-normal">{item.discussion.category.name}</p>
|
||||
<h2 className="max-w-[350px] truncate text-xl font-semibold">
|
||||
{item.discussion.title}
|
||||
</h2>
|
||||
</div>
|
||||
<Link href={item.discussion.author.url}>
|
||||
<a className="flex w-fit items-center space-x-2">
|
||||
<Image
|
||||
className="rounded-full"
|
||||
src={item.discussion.author.avatarUrl}
|
||||
height={30}
|
||||
width={30}
|
||||
/>
|
||||
<span className="text-sm">{item.discussion.author.login}</span>
|
||||
</a>
|
||||
</Link>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
{data.repository.discussions.nodes
|
||||
.slice(page * 10 - 10, page * 10)
|
||||
.map((discussion: any) => {
|
||||
return (
|
||||
<div className="border-scale-600 border-b pb-6 md:pb-0" key={discussion.id}>
|
||||
<Link href={`/discussions/${discussion.number}`}>
|
||||
<a className="mb-6 items-center justify-between md:mx-6 md:flex">
|
||||
<div className="flex items-center">
|
||||
<div className="item-center flex items-center md:space-x-6">
|
||||
<div className="item-center bg-scale-400 border-scale-600 hidden h-fit items-center space-x-1 rounded-xl border py-1 pl-1 pr-2 md:flex">
|
||||
<IconArrowUp className="stroke-2" height={15} />
|
||||
<span>{discussion.upvoteCount}</span>
|
||||
</div>
|
||||
<div className="rounded-lg bg-gray-400 px-3 py-2 md:px-5 md:py-4">
|
||||
{getEmoji(discussion.category.emoji)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-6">
|
||||
<h2 className="text-base font-semibold md:max-w-sm md:text-lg lg:max-w-4xl">
|
||||
{discussion.title}
|
||||
</h2>
|
||||
<span className="text-scale-1000 text-xs">
|
||||
<Link href={discussion.author.url}>
|
||||
<a>{discussion.author.login}</a>
|
||||
</Link>
|
||||
<span>{` asked ${getTimeSinceCreated(discussion.createdAt)} • `}</span>
|
||||
<span className={discussion.answer?.isAnswer ? 'text-brand-900' : ''}>
|
||||
{discussion.answer?.isAnswer ? 'Answered' : 'Unanswered'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-16 mt-2 flex items-center space-x-2 md:ml-0 md:mt-0">
|
||||
<div className="flex items-center -space-x-2">
|
||||
<Link href={discussion.author.url}>
|
||||
<a>
|
||||
<Image
|
||||
className="rounded-full"
|
||||
src={discussion.author.avatarUrl}
|
||||
height={30}
|
||||
width={30}
|
||||
/>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<div
|
||||
className={`item-center flex items-center space-x-1 ${
|
||||
discussion.answer?.isAnswer ? 'text-brand-900' : ''
|
||||
}`}
|
||||
>
|
||||
<IconCheckCircle className="stroke-2" height={15} />
|
||||
<span>{discussion.comments.totalCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
<Pagination currentPage={page} totalCount={data.repository.discussions.totalCount} />
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default discussions
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getDocsBySlug } from '../lib/docs'
|
||||
import Layout from '../components/layouts/Layout'
|
||||
import Layout from '../components/layouts/DocsLayout'
|
||||
import { serialize } from 'next-mdx-remote/serialize'
|
||||
import { MDXRemote } from 'next-mdx-remote'
|
||||
import { MDXProvider } from '@mdx-js/react'
|
||||
|
||||
@@ -14,7 +14,7 @@ It's been another packed month of shipping like crazy at Supabase, here's the up
|
||||
|
||||
## Wildcard Auth Redirects
|
||||
|
||||
Wildcard redirects are especially useful for Jamstack platforms with preview deployments, like Vercel and Netlify. Now you can add a wildcard domain, like `*.mydomain.com/welcome` and both `x.mydomain.com/welcome` and `y.mydomain.com/welcome` will be be accepted. [Read the Pull Request](https://github.com/supabase/gotrue/pull/334).
|
||||
Wildcard redirects are especially useful for Jamstack platforms with preview deployments, like Vercel and Netlify. Now you can add a wildcard domain, like `*.mydomain.com/welcome` and both `x.mydomain.com/welcome` and `y.mydomain.com/welcome` will be accepted. [Read the Pull Request](https://github.com/supabase/gotrue/pull/334).
|
||||
|
||||
[](https://github.com/supabase/gotrue/pull/334)
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ Before we dive in, let’s look at some prerequisites you'll need:
|
||||
|
||||
|
||||
## Loading data into Supabase using Python
|
||||
Supabase is built for developers, and you can [get started for free](https://app.supabase.io) using your existing Github account. Once your Supabase account is set up, you will access the Supabase dashboard. From here, go to All Project > New Project.
|
||||
Supabase is built for developers, and you can [get started for free](https://app.supabase.com) using your existing Github account. Once your Supabase account is set up, you will access the Supabase dashboard. From here, go to All Project > New Project.
|
||||
|
||||

|
||||
|
||||
@@ -173,6 +173,6 @@ Data can also be viewed directly from the Supabase dashboard. To do this, go to
|
||||
Note: In case you cannot see any of the data, you should hit the Refresh button.
|
||||
|
||||
## Conclusion
|
||||
With Python, data loading into Supabase is easy. It just takes a few easy steps to get started with the Python SDK and Supabase. In the next part of this blog series, we will learn how to visualize the data that we just loaded into Supabase using Metabase. Stay tuned!
|
||||
With Python, data loading into Supabase is easy. It just takes a few easy steps to get started with the Python SDK and Supabase. In the next part of this blog series, we will learn [how to visualize the data that we just loaded into Supabase using Metabase](blog/2022/06/29/visualizing-supabase-data-using-metabase). Stay tuned!
|
||||
|
||||
If you have any questions please reach out via [Twitter](https://twitter.com/supabase) or join our [Discord](https://discord.supabase.com).
|
||||
@@ -0,0 +1,135 @@
|
||||
---
|
||||
title: Visualizing Supabase Data using Metabase
|
||||
description: How to create different kinds of charts out of data stored in Supabase using Metabase.
|
||||
author: ant_wilson
|
||||
image: python-1/supabase-python-metabase.png
|
||||
thumb: python-1/supabase-python-metabase.png
|
||||
tags:
|
||||
- python
|
||||
- open-source
|
||||
- community
|
||||
date: '2022-06-29'
|
||||
toc_depth: 3
|
||||
---
|
||||
|
||||
Data helps organizations make better decisions. With a programming language like Python to analyze your data and transform data into visual representations, you can effortlessly tell the story of your business. One way to create customized visuals from your data would be to use data visualization libraries in Python like [Matplotlib](https://matplotlib.org/), [Seaborn](https://seaborn.pydata.org/), [Ggplot2](https://ggplot2.tidyverse.org/index.html), [Plotly](https://plotly.com/), or [Pandas](https://pandas.pydata.org/). When you want to accomplish this task with little or no code (not even SQL), you might consider using tools like [Metabase](https://www.metabase.com/).
|
||||
|
||||
With Metabase, a powerful visualization tool, you can quickly turn your data into easy-to-understand visuals like graphs, pie charts, flow diagrams, and much more. Then, using Metabase’s intuitive interface, you can cut through the data noise and focus on what’s essential for your business.
|
||||
|
||||
In the previous blog of this series**,** we explained [how to use Python to load data into Supabase](https://supabase.com/blog/2022/06/15/loading-data-supabase-python). In this blog, we will create different kinds of charts out of the data stored in Supabase using Metabase.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before we dive in, let’s look at some prerequisites that you will need:
|
||||
|
||||
- **Supabase project with data**
|
||||
|
||||
Based on our [previous article](https://supabase.com/blog/2022/06/15/loading-data-supabase-python), we assume we already have a Supabase project setup and have data loaded into it.
|
||||
|
||||
- **Metabase Docker Container**
|
||||
|
||||
To take advantage of the open-source version of Metabase, you can use the Metabase docker container [here](https://docs.docker.com/engine/install/).
|
||||
|
||||
## Visualizing data in Supabase with Metabase
|
||||
|
||||
### Launching Metabase
|
||||
|
||||
To launch Metabase, simply go to "<http://localhost:3000/setup/>" which is the default port that the Metabase server will be listening to.
|
||||
|
||||
After Metabase is launched, select your preferred language and add your contact information. In the _Add your data_ markdown, you will need to choose PostgreSQL.
|
||||
|
||||

|
||||
|
||||
You will be prompted to add the necessary connection information to your Supabase project. Go to your [Supabase project](https://app.supabase.com/) and hit _Settings > Database to get the database info_.
|
||||
|
||||

|
||||
|
||||
Enter the necessary information on Metabase and hit next. Finally, select your data preference, after which you will land on the Metabase homepage.
|
||||
|
||||
### View Database and Tables
|
||||
|
||||
We can now see the "Supabase DB" Supabase project under "Our data".
|
||||
|
||||

|
||||
|
||||
To view the tables, go to _SupabaseDB > public_
|
||||
|
||||

|
||||
|
||||
### View Table Data Insights
|
||||
|
||||
Go back to the home page and select public schema under "Try these x-rays based on your data"
|
||||
|
||||

|
||||
|
||||
Here is the output of the product table.
|
||||
|
||||

|
||||
|
||||
As you can see, we can get some handy information from this like:
|
||||
|
||||
- How many products are present with a given range of inventory count.
|
||||
- How many products are present for a given range of price.
|
||||
- The ratio between the number of employees to the number of products.
|
||||
- How many products each vendor has created.
|
||||
|
||||
If you have column-specific views, you can select the _zoom-in_ option under _More x-rays_.
|
||||
|
||||

|
||||
|
||||
For example, let's select the total employees field.
|
||||
|
||||

|
||||
|
||||
With information like this, you will be able to answer some key questions like
|
||||
|
||||
- What are some common statistics for company employees like average, minimum, maximum, and standard deviation?
|
||||
- What is the distribution of the employees across different geo locations?
|
||||
- What is the distribution of the employees across different vendors?
|
||||
|
||||
## Using custom SQL queries
|
||||
|
||||
We can also use custom queries to set up our dashboards. To do this, go to _New > SQL query._
|
||||
|
||||

|
||||
|
||||
Next, under the database, select "SupabaseDB".
|
||||
|
||||

|
||||
|
||||
We will be using the following SQL query:
|
||||
|
||||
```sql
|
||||
SELECT "Vendor".vendor_name, product_name, "Vendor".total_employees
|
||||
FROM "Product"
|
||||
LEFT JOIN "Vendor" on "Product".vendor_id = "Vendor".vendor_id
|
||||
WHERE "Vendor".total_employees
|
||||
```
|
||||
|
||||
This query should fetch us the vendor name and the product where the number of employees for a given vendor is less than 110.
|
||||
|
||||
To run the SQL query, hit the play button.
|
||||
|
||||

|
||||
|
||||
This will be shown below in the output window. To visualize the data, hit the visualization button.
|
||||
|
||||

|
||||
|
||||
Next, select the type of visualizer you want. Let us choose _Bar_.
|
||||
|
||||

|
||||
|
||||
Choose the appropriate x-axis and y-axis fields, and you will be able to view the data in bar format.
|
||||
|
||||

|
||||
|
||||
## Conclusion
|
||||
|
||||
Data visualization empowers organizations to turn unused data into actionable insights, leading to faster and better decision-making. Why wait?
|
||||
|
||||

|
||||
|
||||
With our [free tier Supabase account](https://app.supabase.com/), you can start a new project today and use Metabase to visualize your app data.
|
||||
|
||||
If you have any questions please reach out via [Twitter](https://twitter.com/supabase) or join our [Discord](https://discord.supabase.com).
|
||||
@@ -84,6 +84,7 @@ function AuthPage() {
|
||||
<img
|
||||
src="https://app.supabase.com/img/supabase-dark.svg"
|
||||
width="96"
|
||||
alt="supabase logo"
|
||||
/>
|
||||
<h3>{companyName}</h3>
|
||||
</Space>
|
||||
|
||||
@@ -12,6 +12,7 @@ export default function Avatar(props: Props) {
|
||||
src={'/images/blog/avatars/' + img}
|
||||
className="h-8 w-8 rounded-full object-cover text-center"
|
||||
style={{ margin: 0 }}
|
||||
alt={`${caption} avatar`}
|
||||
/>
|
||||
<figcaption style={{ marginTop: 0 }} className="text-scale-1000">
|
||||
<p>{caption}</p>
|
||||
|
||||
@@ -42,6 +42,7 @@ const BlogListItem = ({ post }: Props) => {
|
||||
}
|
||||
objectFit="cover"
|
||||
className="scale-100 transform duration-100 ease-in group-hover:scale-105"
|
||||
alt="case study thumbnail"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ function ExampleCard(props: any) {
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
{props.vercel_deploy_url && (
|
||||
<a target="_blank" href={props.vercel_deploy_url}>
|
||||
<img className="h-6" src="https://vercel.com/button" />
|
||||
<img className="h-6" src="https://vercel.com/button" alt="vercel button" />
|
||||
</a>
|
||||
)}
|
||||
{props.demo_url && (
|
||||
|
||||
@@ -12,30 +12,37 @@ function FloatingIcons() {
|
||||
<img
|
||||
className={`${Styles['icon-react-one']}`}
|
||||
src={`${basePath}/images/product/auth/react-icon.svg`}
|
||||
alt="react icon"
|
||||
/>
|
||||
<img
|
||||
src={`${basePath}/images/product/auth/electron-icon.svg`}
|
||||
className={`${Styles['icon-electron']}`}
|
||||
alt="electron icon"
|
||||
/>
|
||||
<img
|
||||
src={`${basePath}/images/product/auth/vue-icon.svg`}
|
||||
className={`${Styles['icon-vue']}`}
|
||||
alt="vue icon"
|
||||
/>
|
||||
<img
|
||||
src={`${basePath}/images/product/auth/angular-icon.svg`}
|
||||
className={`${Styles['icon-angular']}`}
|
||||
alt="angular icon"
|
||||
/>
|
||||
<img
|
||||
src={`${basePath}/images/product/auth/flutter-icon.svg`}
|
||||
className={`${Styles['icon-flutter']}`}
|
||||
alt="flutter icon"
|
||||
/>
|
||||
<img
|
||||
src={`${basePath}/images/product/auth/nuxt-icon.svg`}
|
||||
className={`${Styles['icon-nuxt']}`}
|
||||
alt="nuxt icon"
|
||||
/>
|
||||
<img
|
||||
src={`${basePath}/images/product/auth/redwood-icon.svg`}
|
||||
className={`${Styles['icon-redwood']}`}
|
||||
alt="redwood icon"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,13 @@ function TweetCard(props: TweetCard) {
|
||||
<div className="relative">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-10 w-10 overflow-hidden rounded-full border dark:border-gray-600">
|
||||
<Image src={props.img_url} layout="responsive" width="64" height="64" />
|
||||
<Image
|
||||
src={props.img_url}
|
||||
layout="responsive"
|
||||
width="64"
|
||||
height="64"
|
||||
alt={`${props.handle} twitter image`}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-scale-1200 mt-3 text-sm font-medium">{props.handle}</p>
|
||||
<div
|
||||
|
||||
@@ -48,6 +48,12 @@ module.exports = withMDX({
|
||||
source: '/docs',
|
||||
destination: `${process.env.NEXT_PUBLIC_DOCS_URL}`,
|
||||
},
|
||||
{
|
||||
// redirect /docs/
|
||||
// trailing slash caused by docusaurus issue with multizone
|
||||
source: '/docs/',
|
||||
destination: `${process.env.NEXT_PUBLIC_DOCS_URL}`,
|
||||
},
|
||||
{
|
||||
source: '/docs/:path*',
|
||||
destination: `${process.env.NEXT_PUBLIC_DOCS_URL}/:path*`,
|
||||
|
||||
@@ -109,6 +109,7 @@ function Database() {
|
||||
src={`${basePath}/images/product/database/postgresql-icon.svg`}
|
||||
width={30}
|
||||
height={30}
|
||||
alt="postgresql icon"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 451 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 610 KiB |
@@ -5,9 +5,17 @@
|
||||
<link>https://supabase.com</link>
|
||||
<description>Latest news from Supabase</description>
|
||||
<language>en</language>
|
||||
<lastBuildDate>Wed, 22 Jun 2022 00:00:00 GMT</lastBuildDate>
|
||||
<lastBuildDate>Wed, 29 Jun 2022 00:00:00 GMT</lastBuildDate>
|
||||
<atom:link href="https://supabase.com/blog/rss.xml" rel="self" type="application/rss+xml"/>
|
||||
|
||||
<item>
|
||||
<guid>https://supabase.com/blog/2022/06/29/visualizing-supabase-data-using-metabase</guid>
|
||||
<title>Visualizing Supabase Data using Metabase</title>
|
||||
<link>https://supabase.com/blog/2022/06/29/visualizing-supabase-data-using-metabase</link>
|
||||
<description>How to create different kinds of charts out of data stored in Supabase using Metabase.</description>
|
||||
<pubDate>Wed, 29 Jun 2022 00:00:00 GMT</pubDate>
|
||||
</item>
|
||||
|
||||
<item>
|
||||
<guid>https://supabase.com/blog/2022/06/15/loading-data-supabase-python</guid>
|
||||
<title>Python data loading with Supabase</title>
|
||||
|
||||
@@ -8,6 +8,7 @@ JWT_SECRET=your-super-secret-jwt-token-with-at-least-32-characters-long
|
||||
ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE
|
||||
SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q
|
||||
|
||||
|
||||
############
|
||||
# Database - You can change these to any PostgreSQL database that has logical replication enabled.
|
||||
############
|
||||
@@ -17,6 +18,7 @@ POSTGRES_DB=postgres
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PORT=5432
|
||||
|
||||
|
||||
############
|
||||
# API Proxy - Configuration for the Kong Reverse proxy.
|
||||
############
|
||||
@@ -25,6 +27,13 @@ KONG_HTTP_PORT=8000
|
||||
KONG_HTTPS_PORT=8443
|
||||
|
||||
|
||||
############
|
||||
# API - Configuration for PostgREST.
|
||||
############
|
||||
|
||||
PGRST_DB_SCHEMAS=public,storage,graphql_public
|
||||
|
||||
|
||||
############
|
||||
# Auth - Configuration for the GoTrue authentication server.
|
||||
############
|
||||
@@ -49,6 +58,7 @@ SMTP_SENDER_NAME=fake_sender
|
||||
ENABLE_PHONE_SIGNUP=true
|
||||
ENABLE_PHONE_AUTOCONFIRM=true
|
||||
|
||||
|
||||
############
|
||||
# Studio - Configuration for the Dashboard
|
||||
############
|
||||
|
||||
@@ -85,7 +85,7 @@ services:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PGRST_DB_URI: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
|
||||
PGRST_DB_SCHEMAS: public,storage, graphql_public
|
||||
PGRST_DB_SCHEMAS: ${PGRST_DB_SCHEMAS}
|
||||
PGRST_DB_ANON_ROLE: anon
|
||||
PGRST_JWT_SECRET: ${JWT_SECRET}
|
||||
PGRST_DB_USE_LEGACY_GUCS: "false"
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function Footer({}) {
|
||||
target="_blank"
|
||||
href="https://vercel.com/new/git/external?repository-url=https%3A%2F%2Fgithub.com%2Fsupabase%2Fsupabase%2Ftree%2Fmaster%2Fexamples%2user-management%2Fnextjs-ts-user-management&project-name=supabase-user-management&repository-name=supabase-user-management&demo-title=Supabase%20User%20Management&demo-description=An%20example%20web%20app%20using%20Supabase%20and%20Next.js&demo-url=https%3A%2F%2Fsupabase-nextjs-ts-user-management.vercel.app&demo-image=https%3A%2F%2Fi.imgur.com%2FZ3HkQqe.png&integration-ids=oac_jUduyjQgOyzev1fjrW83NYOv&external-id=nextjs-user-management"
|
||||
>
|
||||
<img src="https://vercel.com/button" />
|
||||
<img src="https://vercel.com/button" alt="vercel button" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,7 @@ const RouteValidationWrapper: FC = ({ children }) => {
|
||||
// or prompt the user to create an organaization
|
||||
// this is used by database.dev, usually as /new/new-project
|
||||
'/new/[slug]',
|
||||
'/join',
|
||||
]
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,9 +12,9 @@ const InviteUserModal = () => {
|
||||
const inputRef = useRef<any>(null)
|
||||
const { ui } = useStore()
|
||||
|
||||
const [visible, setVisible] = useState<boolean>(false)
|
||||
const [loading, setLoading] = useState<boolean>(false)
|
||||
const [emailValue, setEmailValue] = useState<string>('')
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [emailValue, setEmailValue] = useState('')
|
||||
|
||||
function handleToggle() {
|
||||
// reset data before showing modal again
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import dayjs from 'dayjs'
|
||||
import Link from 'next/link'
|
||||
import { FC, FormEvent, useState } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { IconSearch, Input, Listbox, Popover, Button, Modal } from '@supabase/ui'
|
||||
import { IconSearch, IconInfo, Input, Listbox, Popover, Button, Modal } from '@supabase/ui'
|
||||
|
||||
import { post } from 'lib/common/fetch'
|
||||
import { API_URL, PROJECT_STATUS } from 'lib/constants'
|
||||
@@ -26,10 +27,22 @@ const PITRBackupSelection: FC<Props> = () => {
|
||||
const { app, ui, backups } = useStore()
|
||||
const isAvailable = useFlag('pitrBackups')
|
||||
|
||||
const projectId = ui.selectedProject?.id ?? -1
|
||||
const projectRef = ui.selectedProject?.ref ?? 'default'
|
||||
|
||||
if (!isAvailable) {
|
||||
return (
|
||||
<div className="flex items-center justify-center rounded border border-gray-500 bg-gray-300 py-8">
|
||||
<p className="text-scale-1000 text-sm">Coming soon</p>
|
||||
<div className="block w-full rounded border border-gray-400 border-opacity-50 bg-gray-300 p-3">
|
||||
<div className="flex space-x-3">
|
||||
<IconInfo size={20} strokeWidth={1.5} />
|
||||
<p className="text-scale-1100 text-sm">
|
||||
Coming soon - Point in time backups is an Enterprise feature. Reach out to us{' '}
|
||||
<Link href={`/support/new?ref=${projectRef}&category=sales`}>
|
||||
<a className="text-brand-900">here</a>
|
||||
</Link>{' '}
|
||||
if you're interested!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -42,8 +55,6 @@ const PITRBackupSelection: FC<Props> = () => {
|
||||
const [recoveryPoint, setRecoveryPoint] = useState<string>()
|
||||
|
||||
const { configuration } = backups
|
||||
const projectId = ui.selectedProject?.id ?? -1
|
||||
const projectRef = ui.selectedProject?.ref ?? 'default'
|
||||
|
||||
if (backups.isLoading) {
|
||||
return <Loading />
|
||||
@@ -58,13 +69,19 @@ const PITRBackupSelection: FC<Props> = () => {
|
||||
configuration.physicalBackupData.latestPhysicalBackupDateUnix
|
||||
|
||||
if (!configuration.walg_enabled) {
|
||||
// Using this check as opposed to checking price tier to allow enabling PITR for our own internal projects
|
||||
return (
|
||||
<UpgradeToPro
|
||||
primaryText="Free Plan does not include project backups."
|
||||
projectRef={projectRef}
|
||||
secondaryText="Please upgrade to Pro plan for access to point in time recovery backups."
|
||||
/>
|
||||
<div className="block w-full rounded border border-gray-400 border-opacity-50 bg-gray-300 p-3">
|
||||
<div className="flex space-x-3">
|
||||
<IconInfo size={20} strokeWidth={1.5} />
|
||||
<p className="text-sm">
|
||||
Point in time backups is an Enterprise feature. Reach out to us{' '}
|
||||
<Link href={`/support/new?ref=${projectRef}&category=sales`}>
|
||||
<a className="text-brand-900">here</a>
|
||||
</Link>{' '}
|
||||
if you're interested!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
} else if (!hasPhysicalBackups) {
|
||||
return <BackupsEmpty />
|
||||
|
||||
@@ -96,7 +96,7 @@ const TableList: FC<{
|
||||
<Table.th key="buttons"></Table.th>,
|
||||
]}
|
||||
body={tables.map((x: any, i: any) => (
|
||||
<Table.tr key={x.id}>
|
||||
<Table.tr key={x.id} hoverable>
|
||||
<Table.td>
|
||||
<Typography.Text>{x.name}</Typography.Text>
|
||||
</Table.td>
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { isNil } from 'lodash'
|
||||
import { post } from 'lib/common/fetch'
|
||||
import { Button, IconKey, IconMail, Form, Input, Modal } from '@supabase/ui'
|
||||
import { API_URL } from 'lib/constants'
|
||||
import { useOrganizationDetail, useStore } from 'hooks'
|
||||
import { toJS } from 'mobx'
|
||||
import { Member } from 'types'
|
||||
|
||||
function InviteMemberModal({ organization, members = [], user }: any) {
|
||||
const initialValues = { email: '' }
|
||||
const { ui } = useStore()
|
||||
const { mutateOrgMembers } = useOrganizationDetail(ui.selectedOrganization?.slug || '')
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [emailAddress, setEmailAddress] = useState('')
|
||||
const [memberList, setMemberList] = useState<Member[]>([])
|
||||
const [addMemberLoading, setAddMemberLoading] = useState(false)
|
||||
|
||||
const { slug: orgSlug } = organization
|
||||
|
||||
useEffect(() => {
|
||||
setMemberList(members)
|
||||
}, [members])
|
||||
|
||||
function toggle() {
|
||||
// reset data before showing modal again
|
||||
if (!isOpen) {
|
||||
setAddMemberLoading(false)
|
||||
setEmailAddress('')
|
||||
}
|
||||
setIsOpen(!isOpen)
|
||||
}
|
||||
|
||||
async function addMember() {
|
||||
setAddMemberLoading(true)
|
||||
|
||||
const response = await post(`${API_URL}/organizations/${orgSlug}/members/invite`, {
|
||||
invited_email: emailAddress,
|
||||
owner_id: toJS(user.id),
|
||||
})
|
||||
|
||||
if (isNil(response)) {
|
||||
ui.setNotification({ category: 'error', message: 'Failed to add member' })
|
||||
} else if (response?.error) {
|
||||
ui.setNotification({
|
||||
category: 'error',
|
||||
message: `Failed to add member: ${response.error.message}`,
|
||||
})
|
||||
setAddMemberLoading(false)
|
||||
} else {
|
||||
const newMember: Member = {
|
||||
// [Joshen] Setting a random id for now to fit the Member interface
|
||||
id: 0,
|
||||
invited_at: response.invited_at,
|
||||
is_owner: false,
|
||||
profile: {
|
||||
id: 0,
|
||||
primary_email: response.invited_email,
|
||||
username: response.invited_email[0],
|
||||
},
|
||||
}
|
||||
|
||||
mutateOrgMembers([...memberList, newMember])
|
||||
|
||||
ui.setNotification({
|
||||
category: 'success',
|
||||
message: 'Successfully added new member.',
|
||||
})
|
||||
toggle()
|
||||
}
|
||||
}
|
||||
|
||||
function onEmailInputChange(e: any) {
|
||||
setEmailAddress(e.target.value)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={toggle}>Invite</Button>
|
||||
<Modal
|
||||
size="small"
|
||||
className="!overflow-visible"
|
||||
visible={isOpen}
|
||||
onCancel={toggle}
|
||||
header="Invite a member to this organization"
|
||||
description="Members you'd like to invite must already be registered on Supabase"
|
||||
layout="vertical"
|
||||
hideFooter
|
||||
>
|
||||
<Modal.Content>
|
||||
<Form
|
||||
validate={(values) => {
|
||||
const errors: any = {}
|
||||
if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i.test(values.email)) {
|
||||
errors.email = 'Please enter a valid email address'
|
||||
}
|
||||
return errors
|
||||
}}
|
||||
initialValues={initialValues}
|
||||
onSubmit={addMember}
|
||||
>
|
||||
{() => (
|
||||
<div className="w-full py-4">
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
id="email"
|
||||
icon={<IconMail />}
|
||||
autoFocus
|
||||
placeholder="Enter email address"
|
||||
onChange={onEmailInputChange}
|
||||
label="Email address"
|
||||
/>
|
||||
|
||||
<Button block size="medium" htmlType="submit" loading={addMemberLoading}>
|
||||
Invite new member
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default InviteMemberModal
|
||||
@@ -67,7 +67,7 @@ const TableGridEditor: FC<Props> = ({
|
||||
// @ts-ignore
|
||||
const schema = meta.schemas.list().find((schema) => schema.name === selectedSchema)
|
||||
const isViewSelected = Object.keys(selectedTable).length === 2
|
||||
const isLocked = schema?.owner === 'supabase_admin'
|
||||
const isLocked = meta.excludedSchemas.includes(schema?.name ?? '')
|
||||
|
||||
const gridTable = !isViewSelected
|
||||
? parseSupaTable({
|
||||
|
||||
@@ -93,11 +93,10 @@ const TableEditorMenu: FC<Props> = ({
|
||||
? schemaViews
|
||||
: schemaViews.filter((view) => view.name.includes(searchText))
|
||||
|
||||
const isLocked = schema?.owner === 'supabase_admin'
|
||||
const [protectedSchemas, openSchemas] = partition(
|
||||
schemas,
|
||||
(schema) => schema.owner === 'supabase_admin'
|
||||
const [protectedSchemas, openSchemas] = partition(schemas, (schema) =>
|
||||
meta.excludedSchemas.includes(schema?.name ?? '')
|
||||
)
|
||||
const isLocked = protectedSchemas.some((s) => s.id === schema?.id)
|
||||
|
||||
return (
|
||||
<div className="my-6 mx-4 flex flex-grow flex-col space-y-6">
|
||||
|
||||
@@ -1,323 +0,0 @@
|
||||
import { createContext, useContext, useCallback, useState, useEffect } from 'react'
|
||||
import { debounce, isNil } from 'lodash'
|
||||
import { observer, useLocalObservable } from 'mobx-react-lite'
|
||||
import { post } from 'lib/common/fetch'
|
||||
import { Button, IconKey, IconLoader, IconUser, IconX, Input, Typography } from '@supabase/ui'
|
||||
import { Modal } from '@supabase/ui'
|
||||
|
||||
import { API_URL } from 'lib/constants'
|
||||
import { Transition } from '@headlessui/react'
|
||||
import { useOrganizationDetail, useStore } from 'hooks'
|
||||
|
||||
/**
|
||||
* Modal to invite member to Organization
|
||||
*
|
||||
* @param {Object} organization // organization detail
|
||||
* @param {Array} members // organization members list
|
||||
*/
|
||||
|
||||
const PageContext = createContext(null)
|
||||
function InviteMemberModal({ organization, members = [] }) {
|
||||
const PageState = useLocalObservable(() => ({
|
||||
members: [],
|
||||
keywords: '',
|
||||
profiles: null,
|
||||
selectedProfile: null,
|
||||
addMemberLoading: false,
|
||||
get addBtnText() {
|
||||
if (this.selectedProfile) return `Add ${this.selectedProfile.username} to organization`
|
||||
return 'Select a member above'
|
||||
},
|
||||
get addBtnDisable() {
|
||||
return !this.selectedProfile || this.addMemberLoading
|
||||
},
|
||||
setProfiles(profiles) {
|
||||
let temp = profiles
|
||||
if (profiles) {
|
||||
temp = profiles
|
||||
.map((profile) => {
|
||||
const foundMember = this.members.find((x) => x.profile.id == profile.id)
|
||||
return { ...profile, isMember: foundMember !== undefined }
|
||||
})
|
||||
.sort((a, b) => a.username.localeCompare(b.username))
|
||||
}
|
||||
this.profiles = temp
|
||||
},
|
||||
}))
|
||||
const { ui } = useStore()
|
||||
const { mutateOrgMembers } = useOrganizationDetail(ui.selectedOrganization?.slug || '')
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const { id: orgId, slug: orgSlug } = organization
|
||||
|
||||
useEffect(() => {
|
||||
PageState.members = members
|
||||
}, [members])
|
||||
|
||||
function toggle() {
|
||||
// reset data before showing modal again
|
||||
if (!isOpen) {
|
||||
PageState.keywords = ''
|
||||
PageState.selectedProfile = null
|
||||
PageState.profiles = null
|
||||
PageState.addMemberLoading = false
|
||||
}
|
||||
setIsOpen(!isOpen)
|
||||
}
|
||||
|
||||
async function addMember() {
|
||||
PageState.addMemberLoading = true
|
||||
const response = await post(`${API_URL}/organizations/${orgSlug}/members/add`, {
|
||||
org_id: orgId,
|
||||
user_id: PageState.selectedProfile.id,
|
||||
})
|
||||
if (isNil(response)) {
|
||||
ui.setNotification({ category: 'error', message: 'Failed to add member' })
|
||||
} else if (response?.error) {
|
||||
ui.setNotification({
|
||||
category: 'error',
|
||||
message: `Failed to add member: ${response.error.message}`,
|
||||
})
|
||||
PageState.addMemberLoading = false
|
||||
} else {
|
||||
const newMember = response
|
||||
mutateOrgMembers([...PageState.members, newMember], false)
|
||||
toggle()
|
||||
ui.setNotification({ category: 'success', message: 'Successfully added new member' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContext.Provider value={PageState}>
|
||||
<Button onClick={toggle}>Invite</Button>
|
||||
<Modal
|
||||
size="small"
|
||||
className="!overflow-visible"
|
||||
icon={<IconKey size="xlarge" background="brand" />}
|
||||
visible={isOpen}
|
||||
onCancel={toggle}
|
||||
header="Invite a member to organization"
|
||||
description="Members you'd like to invite must already be registered on Supabase"
|
||||
layout="vertical"
|
||||
hideFooter
|
||||
>
|
||||
<div className="w-full py-4 space-y-4">
|
||||
<Modal.Content>
|
||||
<div className="text-center">
|
||||
<InputSearchWithResults className="" />
|
||||
</div>
|
||||
</Modal.Content>
|
||||
<Modal.Seperator />
|
||||
<Modal.Content>
|
||||
<div className="">
|
||||
<Button
|
||||
onClick={addMember}
|
||||
loading={PageState.addMemberLoading}
|
||||
disabled={PageState.addBtnDisable}
|
||||
block
|
||||
>
|
||||
{PageState.addBtnText}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal.Content>
|
||||
</div>
|
||||
</Modal>
|
||||
</PageContext.Provider>
|
||||
)
|
||||
}
|
||||
export default observer(InviteMemberModal)
|
||||
|
||||
const InputSearchWithResults = observer(({ className }) => {
|
||||
const PageState = useContext(PageContext)
|
||||
const { ui } = useStore()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const debounceSearchProfile = useCallback(debounce(searchProfile, 500), [PageState.keywords])
|
||||
|
||||
useEffect(() => {
|
||||
debounceSearchProfile()
|
||||
// Cancel the debounce on useEffect cleanup.
|
||||
return debounceSearchProfile.cancel
|
||||
}, [PageState.keywords, debounceSearchProfile])
|
||||
|
||||
async function searchProfile() {
|
||||
if (PageState.keywords.length === 0) {
|
||||
PageState.setProfiles(null)
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
const response = await post(`${API_URL}/profile/search`, { keywords: PageState.keywords })
|
||||
if (isNil(response)) {
|
||||
ui.setNotification({ category: 'error', message: 'Failed to search profile' })
|
||||
} else if (response.error) {
|
||||
ui.setNotification({
|
||||
category: 'error',
|
||||
message: `Failed to search profile: ${response.error.message}`,
|
||||
})
|
||||
setLoading(false)
|
||||
} else {
|
||||
PageState.setProfiles(response)
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function onInputChange(e) {
|
||||
PageState.keywords = e.target.value
|
||||
}
|
||||
|
||||
function reset() {
|
||||
PageState.keywords = ''
|
||||
PageState.setProfiles(null)
|
||||
PageState.selectedProfile = null
|
||||
}
|
||||
|
||||
if (PageState.selectedProfile) {
|
||||
const fullName = `${PageState.selectedProfile.first_name || ''} ${
|
||||
PageState.selectedProfile.last_name || ''
|
||||
}`
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="flex px-6 py-2 items-center rounded bg-scale-400 border">
|
||||
<div className="flex-grow text-left">
|
||||
<p className="text-scale-1200 font-medium">{PageState.selectedProfile.username}</p>
|
||||
<p className="text-sm leading-5 text-scale-1100">
|
||||
{fullName !== ' ' ? `${fullName} • Invite collaborator` : 'Invite collaborator'}
|
||||
</p>
|
||||
</div>
|
||||
<button className="text-scale-900 hover:text-scale-1200">
|
||||
<IconX strokeWidth={2} onClick={reset} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const onSelectDropdownOption = (e, option) => {
|
||||
if (!option.isMember) PageState.selectedProfile = option
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="relative rounded-md shadow-sm">
|
||||
<Input
|
||||
icon={<IconUser />}
|
||||
autoFocus
|
||||
id="email"
|
||||
type="text"
|
||||
className="form-input"
|
||||
onChange={onInputChange}
|
||||
autoComplete="off"
|
||||
value={PageState.keywords}
|
||||
placeholder="search by username or email"
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
{loading && <IconLoader className="animate-spin" size={16} />}
|
||||
</div>
|
||||
</div>
|
||||
{PageState.profiles && PageState.profiles.length !== 0 ? (
|
||||
<>
|
||||
<div className="flex flex-col gap-1 py-4">
|
||||
{PageState.profiles.map((profile, i) => {
|
||||
const { first_name, last_name, username, isMember } = profile
|
||||
const fullName = `${first_name || ''} ${last_name || ''}`
|
||||
let subText = isMember ? 'Already in this organization' : 'Invite collaborator'
|
||||
if (fullName !== ' ') subText = `${fullName} • ${subText}`
|
||||
return (
|
||||
<Button
|
||||
block
|
||||
key={`option_${i}`}
|
||||
className={`px-3 py-1 ${
|
||||
isMember
|
||||
? 'cursor-not-allowed opacity-50'
|
||||
: 'cursor-pointer hover:bg-bg-alt-light dark:hover:bg-bg-alt-dark'
|
||||
} first:rounded-t-md last:rounded-b-md`}
|
||||
onClick={(e) => onSelectDropdownOption(e, profile)}
|
||||
type="default"
|
||||
style={{ justifyContent: 'flex-start' }}
|
||||
size="medium"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="block truncate">{username}</span>
|
||||
<span className="text-scale-900 block truncate" type="secondary">
|
||||
{subText}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : PageState.profiles &&
|
||||
PageState.profiles.length === 0 &&
|
||||
!loading &&
|
||||
PageState.keywords ? (
|
||||
<>
|
||||
<p className="text-sm text-scale-1200 mt-4">Could not find account.</p>
|
||||
<p className="text-sm text-scale-1100">Has the user already signed up?</p>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const ProfileDropdown = observer(({ className }) => {
|
||||
const PageState = useContext(PageContext)
|
||||
|
||||
const onSelectDropdownOption = (e, option) => {
|
||||
if (!option.isMember) PageState.selectedProfile = option
|
||||
}
|
||||
|
||||
if (!PageState.profiles) return null
|
||||
return (
|
||||
<div className={className}>
|
||||
<Transition
|
||||
show={!PageState.selectedProfile}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<div className="origin-top-right w-full max-h-48 rounded-md shadow-lg overflow-y-auto z-100 fixed">
|
||||
<div className="rounded-md bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 text-left">
|
||||
<div
|
||||
className="py-1"
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
aria-labelledby="options-menu"
|
||||
>
|
||||
{PageState.profiles.length === 0 && (
|
||||
<div className="px-3 py-2">
|
||||
<p className="text-base leading-6 font-medium">
|
||||
Could not find account. Has the user already signed up?
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{PageState.profiles.map((profile, i) => {
|
||||
const { first_name, last_name, username, isMember } = profile
|
||||
const fullName = `${first_name || ''} ${last_name || ''}`
|
||||
let subText = isMember ? 'Already in this organization' : 'Invite collaborator'
|
||||
if (fullName !== ' ') subText = `${fullName} • ${subText}`
|
||||
return (
|
||||
<div
|
||||
key={`option_${i}`}
|
||||
className={`px-3 py-1 ${
|
||||
isMember
|
||||
? 'cursor-not-allowed opacity-50'
|
||||
: 'cursor-pointer hover:bg-bg-alt-light dark:hover:bg-bg-alt-dark'
|
||||
} first:rounded-t-md last:rounded-b-md`}
|
||||
onClick={(e) => onSelectDropdownOption(e, profile)}
|
||||
>
|
||||
<Typography.Text className="block">{username}</Typography.Text>
|
||||
<Typography.Text className="block" type="secondary">
|
||||
{subText}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -4,13 +4,36 @@ import { Member } from 'types'
|
||||
import { API_URL } from 'lib/constants'
|
||||
|
||||
export function useOrganizationDetail(slug: string) {
|
||||
// Get org members
|
||||
const url = `${API_URL}/props/org/${slug}`
|
||||
const { data, error } = useSWR<any>(url, get)
|
||||
const { members, products } = data ?? []
|
||||
const anyError = data?.error || error
|
||||
let { members, products } = data ?? []
|
||||
|
||||
// Get pending invite users
|
||||
const pendingInviteUrl = `${API_URL}/organizations/${slug}/members/invite`
|
||||
const { data: inviteData, error: inviteError } = useSWR<any>(pendingInviteUrl, get)
|
||||
|
||||
if (data && inviteData && inviteData.length > 0) {
|
||||
// remap invite data to look like existing members data
|
||||
const invitedMembers = inviteData.map((x: any) => ({
|
||||
is_owner: false,
|
||||
invited_at: x.invited_at,
|
||||
invited_id: x.invited_id,
|
||||
profile: {
|
||||
// Use the first letter of the email to allow for member list sorting
|
||||
username: x.invited_email.slice(0, 1),
|
||||
primary_email: x.invited_email,
|
||||
},
|
||||
}))
|
||||
|
||||
members = [...members, ...invitedMembers]
|
||||
}
|
||||
|
||||
const anyError = data?.error || error || inviteError
|
||||
|
||||
function mutateOrgMembers(updatedMembers: Member[], revalidate?: boolean) {
|
||||
mutate(url, { members: updatedMembers }, revalidate ?? true)
|
||||
mutate(pendingInviteUrl, {}, revalidate ?? true)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -59,6 +59,9 @@ const nextConfig = {
|
||||
},
|
||||
]
|
||||
},
|
||||
images: {
|
||||
domains: ['github.com']
|
||||
},
|
||||
}
|
||||
|
||||
// Export all config
|
||||
|
||||
@@ -146,6 +146,8 @@ const IndexLayout = withAuth(
|
||||
router.push(`/vercel/integrate?${params.toString()}`)
|
||||
} else if (router.query?.next?.includes('new-project')) {
|
||||
router.push('/new/project')
|
||||
} else if (router.query?.next?.includes('join')) {
|
||||
router.push(`/${router.query.next}`)
|
||||
} else if (
|
||||
typeof router.query?.next === 'string' &&
|
||||
router.query?.next?.startsWith('project/_/')
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import Link from 'next/link'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { Button, IconCheckSquare, Loading } from '@supabase/ui'
|
||||
|
||||
import { useProfile, useStore } from 'hooks'
|
||||
import { auth } from 'lib/gotrue'
|
||||
import { API_URL } from 'lib/constants'
|
||||
import { get, post, delete_ } from 'lib/common/fetch'
|
||||
|
||||
interface ITokenInfo {
|
||||
organization_name?: string | undefined
|
||||
token_does_not_exist?: boolean
|
||||
email_match?: boolean
|
||||
authorized_user?: boolean
|
||||
expired_token?: boolean
|
||||
invite_id?: number
|
||||
}
|
||||
|
||||
type TokenInfo = ITokenInfo | undefined
|
||||
|
||||
const JoinOrganizationPage = () => {
|
||||
const router = useRouter()
|
||||
const { slug, token } = router.query
|
||||
const { ui, app } = useStore()
|
||||
const { profile } = useProfile()
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState(false)
|
||||
const [tokenValidationInfo, setTokenValidationInfo] = useState<TokenInfo>(undefined)
|
||||
const [tokenInfoLoaded, setTokenInfoLoaded] = useState(false)
|
||||
const { token_does_not_exist, email_match, expired_token, organization_name, invite_id } =
|
||||
tokenValidationInfo || {}
|
||||
|
||||
const loginRedirectLink = `/?next=${encodeURIComponent(`/join?token=${token}&slug=${slug}`)}`
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTokenInfo = async () => {
|
||||
await ui.load()
|
||||
|
||||
const response = await get(`${API_URL}/organizations/${slug}/members/join?token=${token}`)
|
||||
|
||||
if (response.error) {
|
||||
setError(response.error)
|
||||
setTokenInfoLoaded(true)
|
||||
} else {
|
||||
setTokenInfoLoaded(true)
|
||||
setTokenValidationInfo(response)
|
||||
}
|
||||
}
|
||||
|
||||
if (!tokenInfoLoaded && token) {
|
||||
fetchTokenInfo()
|
||||
}
|
||||
|
||||
/**
|
||||
* if params are empty then redirect
|
||||
* user to the homepage of app
|
||||
*/
|
||||
// if (!slug && !token) router.push('/')
|
||||
}, [token, router.asPath])
|
||||
|
||||
async function handleJoinOrganization() {
|
||||
setIsSubmitting(true)
|
||||
const response = await post(`${API_URL}/organizations/${slug}/members/join?token=${token}`, {})
|
||||
|
||||
if (response.error) {
|
||||
ui.setNotification({
|
||||
category: 'error',
|
||||
message: `Failed to join organization: ${response.error.message}`,
|
||||
})
|
||||
setIsSubmitting(false)
|
||||
} else {
|
||||
setIsSubmitting(false)
|
||||
app.onOrgAdded(response)
|
||||
router.push('/')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeclineJoinOrganization() {
|
||||
setIsSubmitting(true)
|
||||
const response = await delete_(
|
||||
`${API_URL}/organizations/${slug}/members/invite?invited_id=${invite_id}`,
|
||||
{}
|
||||
)
|
||||
if (response.error) {
|
||||
ui.setNotification({
|
||||
category: 'error',
|
||||
message: `Failed to decline invitation: ${response.error.message}`,
|
||||
})
|
||||
setIsSubmitting(false)
|
||||
} else {
|
||||
setIsSubmitting(false)
|
||||
router.push('/')
|
||||
}
|
||||
}
|
||||
|
||||
const isError =
|
||||
error ||
|
||||
!!(tokenInfoLoaded && token_does_not_exist) ||
|
||||
(tokenInfoLoaded && !email_match) ||
|
||||
(tokenInfoLoaded && expired_token)
|
||||
|
||||
const ErrorMessage = () => {
|
||||
const Container = ({ children }: { children: React.ReactNode }) => (
|
||||
<div
|
||||
className={[
|
||||
'flex flex-col items-center justify-center gap-3 text-sm',
|
||||
isError ? 'text-scale-1100' : 'text-scale-1200',
|
||||
].join(' ')}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
const message = error ? (
|
||||
<p>There was an error requesting details for this invitation.</p>
|
||||
) : token_does_not_exist ? (
|
||||
<>
|
||||
<p>The invite token is invalid.</p>
|
||||
<p className="text-scale-900">
|
||||
Try copying and pasting the link from the invite email, or ask the organization owner to
|
||||
invite you again.
|
||||
</p>
|
||||
</>
|
||||
) : !email_match ? (
|
||||
<>
|
||||
<p>
|
||||
Your email address {profile?.primary_email} does not match the email address this
|
||||
invitation was sent to.
|
||||
</p>
|
||||
<p className="text-scale-900">
|
||||
To accept this invitation, you will need to{' '}
|
||||
<a
|
||||
className="text-brand-900 cursor-pointer"
|
||||
onClick={async () => {
|
||||
await auth.signOut()
|
||||
router.reload()
|
||||
}}
|
||||
>
|
||||
sign out
|
||||
</a>{' '}
|
||||
and then sign in or create a new account using the same email address used in the
|
||||
invitation.
|
||||
</p>
|
||||
</>
|
||||
) : expired_token ? (
|
||||
<>
|
||||
<p>The invite token has expired.</p>
|
||||
<p className="text-scale-900">Please request a new one from the organization owner.</p>
|
||||
</>
|
||||
) : (
|
||||
''
|
||||
)
|
||||
|
||||
return isError ? <Container>{message}</Container> : null
|
||||
}
|
||||
|
||||
const Content = () => (
|
||||
<>
|
||||
<div className="flex flex-col gap-2 px-6 py-8">
|
||||
<>
|
||||
<p className="text-scale-1200 text-sm">You have been invited to join </p>
|
||||
{organization_name ? (
|
||||
<>
|
||||
<p className="text-scale-1200 text-3xl">
|
||||
{organization_name ? `${organization_name}` : 'an organization'}{' '}
|
||||
</p>
|
||||
{!token_does_not_exist && (
|
||||
<p className="text-scale-900 text-sm">an organization on Supabase</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-scale-1200 text-3xl">{'an organization'}</p>
|
||||
</>
|
||||
)}
|
||||
{slug && <p className="text-scale-900 text-xs">{`organization slug: ${slug}`}</p>}
|
||||
</>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={['border-scale-400 border-t', isError ? 'bg-sand-100' : 'bg-transparent'].join(
|
||||
' '
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-4 px-6 py-4 ">
|
||||
{profile && !isError && (
|
||||
<div className="flex flex-row items-center justify-center gap-3">
|
||||
<Button onClick={handleDeclineJoinOrganization} htmlType="submit" type="default">
|
||||
Decline
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleJoinOrganization}
|
||||
htmlType="submit"
|
||||
loading={isSubmitting}
|
||||
type="primary"
|
||||
icon={<IconCheckSquare />}
|
||||
>
|
||||
Join organization
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tokenInfoLoaded && <ErrorMessage />}
|
||||
|
||||
{!profile && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-scale-900 text-xs">
|
||||
You will need to sign in to accept this invitation
|
||||
</p>
|
||||
<div className="flex justify-center gap-3">
|
||||
<Link passHref href={loginRedirectLink}>
|
||||
<Button as="a" type="default">
|
||||
Sign in
|
||||
</Button>
|
||||
</Link>
|
||||
<Link passHref href={loginRedirectLink}>
|
||||
<Button as="a" type="default">
|
||||
Create an account
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
'bg-scale-200 flex h-full min-h-screen',
|
||||
'w-full flex-col place-items-center',
|
||||
'items-center justify-center gap-8 px-5',
|
||||
].join(' ')}
|
||||
>
|
||||
<Link href="/">
|
||||
<a className="flex items-center justify-center gap-4">
|
||||
<img
|
||||
src="/img/supabase-logo.svg"
|
||||
alt="Supabase"
|
||||
className="block h-[24px] cursor-pointer rounded"
|
||||
/>
|
||||
</a>
|
||||
</Link>
|
||||
<div
|
||||
className="
|
||||
bg-scale-100 border-scale-400 mx-auto overflow-hidden
|
||||
rounded-md border text-center shadow
|
||||
md:w-[400px]
|
||||
"
|
||||
>
|
||||
<Loading active={!tokenInfoLoaded}>
|
||||
<Content />
|
||||
</Loading>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default JoinOrganizationPage
|
||||
@@ -8,6 +8,7 @@ import { organizations } from 'stores/jsonSchema'
|
||||
import {
|
||||
Loading,
|
||||
Button,
|
||||
Badge,
|
||||
IconMoreHorizontal,
|
||||
Tabs,
|
||||
Typography,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
Dropdown,
|
||||
Modal,
|
||||
IconSearch,
|
||||
IconUser,
|
||||
Form,
|
||||
} from '@supabase/ui'
|
||||
|
||||
@@ -29,15 +31,26 @@ import { BillingSettings, InvoicesSettings } from 'components/interfaces/Organiz
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import Panel from 'components/ui/Panel'
|
||||
import { confirmAlert } from 'components/to-be-cleaned/ModalsDeprecated/ConfirmModal'
|
||||
import InviteMemberModal from 'components/to-be-cleaned/ModalsDeprecated/InviteMemberModal'
|
||||
import InviteMemberModal from 'components/interfaces/Organization/InviteMemberModal'
|
||||
import TextConfirmModal from 'components/ui/Modals/TextConfirmModal'
|
||||
import SchemaFormPanel from 'components/to-be-cleaned/forms/SchemaFormPanel'
|
||||
import { NextPageWithLayout, Project } from 'types'
|
||||
import { Member, NextPageWithLayout, Project } from 'types'
|
||||
import Image from 'next/image'
|
||||
|
||||
// [Joshen] Low prio refactor: Bring out general and team settings into their own components too
|
||||
|
||||
const PageContext = createContext(null)
|
||||
|
||||
// Invite is expired if older than 24hrs
|
||||
function inviteExpired(timestamp: Date) {
|
||||
const inviteDate = new Date(timestamp)
|
||||
const now = new Date()
|
||||
var timeBetween = now.valueOf() - inviteDate.valueOf()
|
||||
if (timeBetween / 1000 / 60 / 60 < 24) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const OrgSettingsLayout = withAuth(
|
||||
observer(({ children }) => {
|
||||
const { app, ui } = useStore()
|
||||
@@ -57,10 +70,15 @@ const OrgSettingsLayout = withAuth(
|
||||
get filteredMembers() {
|
||||
const temp = this.members.filter((x: any) => {
|
||||
let profile = x.profile
|
||||
return (
|
||||
profile.username.includes(this.membersFilterString) ||
|
||||
profile.primary_email.includes(this.membersFilterString)
|
||||
)
|
||||
if (profile) {
|
||||
return (
|
||||
profile.username.includes(this.membersFilterString) ||
|
||||
profile.primary_email.includes(this.membersFilterString)
|
||||
)
|
||||
}
|
||||
if (x.invited_email) {
|
||||
return x.invited_email.includes(this.membersFilterString)
|
||||
}
|
||||
})
|
||||
return temp.sort((a: any, b: any) => a.profile.username.localeCompare(b.profile.username))
|
||||
},
|
||||
@@ -123,6 +141,7 @@ export default observer(OrgSettings)
|
||||
const OrganizationSettings = observer(() => {
|
||||
const PageState: any = useContext(PageContext)
|
||||
const { ui } = useStore()
|
||||
|
||||
const {
|
||||
members,
|
||||
products,
|
||||
@@ -426,6 +445,7 @@ const TeamSettings = observer(() => {
|
||||
<InviteMemberModal
|
||||
organization={PageState.organization}
|
||||
members={PageState.members}
|
||||
user={PageState.user}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
@@ -473,6 +493,7 @@ const MembersView = observer(() => {
|
||||
<Table
|
||||
head={[
|
||||
<Table.th key="header-user">User</Table.th>,
|
||||
<Table.th key="header-status"></Table.th>,
|
||||
<Table.th key="header-role">Role</Table.th>,
|
||||
<Table.th key="header-action"></Table.th>,
|
||||
]}
|
||||
@@ -482,19 +503,41 @@ const MembersView = observer(() => {
|
||||
<Table.td>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div>
|
||||
<img
|
||||
src={`https://github.com/${x.profile.username}.png?size=80`}
|
||||
width="40"
|
||||
className="border-border-secondary-light dark:border-border-secondary-dark rounded-full border"
|
||||
/>
|
||||
{x.invited_id ? (
|
||||
<span className="border-border-secondary-light dark:border-border-secondary-dark flex rounded-full border-2 p-2">
|
||||
<IconUser size={18} strokeWidth={2} />
|
||||
</span>
|
||||
) : (
|
||||
<Image
|
||||
src={`https://github.com/${x.profile?.username}.png?size=80`}
|
||||
width="40"
|
||||
height="40"
|
||||
className="border-border-secondary-light dark:border-border-secondary-dark rounded-full border"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text>{x.profile.username}</Typography.Text>
|
||||
<br />
|
||||
<Typography.Text type="secondary">{x.profile.primary_email}</Typography.Text>
|
||||
{x.profile?.username && !x.invited_id && (
|
||||
<>
|
||||
<Typography.Text>{x.profile?.username}</Typography.Text>
|
||||
<br />
|
||||
</>
|
||||
)}
|
||||
<Typography.Text type="secondary">
|
||||
{x.profile ? x.profile.primary_email : ''}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
</Table.td>
|
||||
|
||||
<Table.td>
|
||||
{x.invited_id && (
|
||||
<Badge color={inviteExpired(x.invited_at) ? 'yellow' : 'red'}>
|
||||
{inviteExpired(x.invited_at) ? 'Invited' : 'Expired'}
|
||||
</Badge>
|
||||
)}
|
||||
</Table.td>
|
||||
|
||||
<Table.td>
|
||||
<Typography.Text type="secondary">
|
||||
{x.is_owner ? 'Owner' : 'Developer'}
|
||||
@@ -515,7 +558,7 @@ const MembersView = observer(() => {
|
||||
className="bg-panel-secondary-light dark:bg-panel-secondary-dark"
|
||||
>
|
||||
{/* @ts-ignore */}
|
||||
<Table.td colSpan="3">
|
||||
<Table.td colSpan="4">
|
||||
<Typography.Text type="secondary">
|
||||
{PageState.membersFilterString ? `${PageState.filteredMembers.length} of ` : ''}
|
||||
{PageState.members.length || '0'}{' '}
|
||||
@@ -542,17 +585,13 @@ const OwnerDropdown = observer(({ members, member }: any) => {
|
||||
|
||||
const { id: orgId, slug: orgSlug, stripe_customer_id, name: orgName } = PageState.organization
|
||||
|
||||
function toggleDropDown() {
|
||||
setIsOpen(!isOpen)
|
||||
}
|
||||
|
||||
async function handleMemberDelete() {
|
||||
setIsOpen(false)
|
||||
await timeout(200)
|
||||
|
||||
confirmAlert({
|
||||
title: 'Confirm to remove',
|
||||
message: `This is permanent! Are you sure you want to remove ${member.profile.username} ?`,
|
||||
message: `This is permanent! Are you sure you want to remove ${member.profile.primary_email}?`,
|
||||
onAsyncConfirm: async () => {
|
||||
setLoading(true)
|
||||
const response = await delete_(`${API_URL}/organizations/${orgSlug}/members/remove`, {
|
||||
@@ -579,7 +618,6 @@ const OwnerDropdown = observer(({ members, member }: any) => {
|
||||
const response = await post(`${API_URL}/organizations/${orgSlug}/transfer`, {
|
||||
org_id: orgId,
|
||||
member_id: member.id,
|
||||
stripe_customer_id,
|
||||
})
|
||||
if (response.error) {
|
||||
ui.setNotification({
|
||||
@@ -599,6 +637,49 @@ const OwnerDropdown = observer(({ members, member }: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResendInvite(member: Member) {
|
||||
setLoading(true)
|
||||
|
||||
const response = await post(`${API_URL}/organizations/${orgSlug}/members/invite`, {
|
||||
invited_email: member.profile.primary_email,
|
||||
owner_id: member.invited_id,
|
||||
})
|
||||
|
||||
if (response.error) {
|
||||
ui.setNotification({
|
||||
category: 'error',
|
||||
message: `Failed to resend invitation: ${response.error.message}`,
|
||||
})
|
||||
setLoading(false)
|
||||
} else {
|
||||
const updatedMembers = [...members]
|
||||
mutateOrgMembers(updatedMembers)
|
||||
ui.setNotification({ category: 'success', message: 'Resent the invitation.' })
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevokeInvitation(id: number) {
|
||||
setLoading(true)
|
||||
|
||||
const response = await delete_(
|
||||
`${API_URL}/organizations/${orgSlug}/members/invite?invited_id=${id}`,
|
||||
{}
|
||||
)
|
||||
|
||||
if (response.error) {
|
||||
ui.setNotification({
|
||||
category: 'error',
|
||||
message: `Failed to revoke invitation: ${response.error.message}`,
|
||||
})
|
||||
setLoading(false)
|
||||
} else {
|
||||
const updatedMembers = [...members]
|
||||
mutateOrgMembers(updatedMembers)
|
||||
ui.setNotification({ category: 'success', message: 'Successfully revoked the invitation.' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-end">
|
||||
<Dropdown
|
||||
@@ -606,16 +687,46 @@ const OwnerDropdown = observer(({ members, member }: any) => {
|
||||
align="end"
|
||||
overlay={
|
||||
<>
|
||||
<Dropdown.Item onClick={() => setOwnerTransferIsVisble(!ownerTransferIsVisble)}>
|
||||
<div className="flex flex-col">
|
||||
<p>Make owner</p>
|
||||
<p className="block opacity-50">Transfer ownership of "{orgName}"</p>
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Seperator />
|
||||
<Dropdown.Item icon={<IconTrash size="tiny" />} onClick={handleMemberDelete}>
|
||||
Remove member
|
||||
</Dropdown.Item>
|
||||
{!member.invited_at && (
|
||||
<Dropdown.Item onClick={() => setOwnerTransferIsVisble(!ownerTransferIsVisble)}>
|
||||
<div className="flex flex-col">
|
||||
<p>Make owner</p>
|
||||
<p className="block opacity-50">Transfer ownership of "{orgName}"</p>
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
|
||||
{member.invited_at && (
|
||||
<>
|
||||
<Dropdown.Item onClick={() => handleRevokeInvitation(member.invited_id)}>
|
||||
<div className="flex flex-col">
|
||||
<p>Cancel invitation</p>
|
||||
<p className="block opacity-50">Revoke this invitation.</p>
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
|
||||
{!inviteExpired(member.invited_at) && (
|
||||
<>
|
||||
<Dropdown.Seperator />
|
||||
<Dropdown.Item onClick={() => handleResendInvite(member)}>
|
||||
<div className="flex flex-col">
|
||||
<p>Resend invitation</p>
|
||||
<p className="block opacity-50">Invites expire after 24hrs.</p>
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!member.invited_at && (
|
||||
<>
|
||||
<Dropdown.Seperator />
|
||||
<Dropdown.Item icon={<IconTrash size="tiny" />} onClick={handleMemberDelete}>
|
||||
Remove member
|
||||
</Dropdown.Item>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
@@ -641,7 +752,7 @@ const OwnerDropdown = observer(({ members, member }: any) => {
|
||||
text={
|
||||
<span>
|
||||
By transferring this organization, it will be solely owned by{' '}
|
||||
<span className="font-medium dark:text-white">{member.profile.username}</span>, they
|
||||
<span className="font-medium dark:text-white">{member.profile?.username}</span>, they
|
||||
will also be able to remove you from the organization as a member
|
||||
</span>
|
||||
}
|
||||
|
||||
@@ -135,6 +135,8 @@ export default class MetaStore implements IMetaStore {
|
||||
projectRef?: string
|
||||
connectionString?: string
|
||||
baseUrl: string
|
||||
|
||||
// [Joshen] I'm going to treat this as a list of system schemas
|
||||
excludedSchemas = [
|
||||
'auth',
|
||||
'extensions',
|
||||
@@ -146,6 +148,7 @@ export default class MetaStore implements IMetaStore {
|
||||
'storage',
|
||||
'supabase_functions',
|
||||
'graphql',
|
||||
'graphql_public',
|
||||
]
|
||||
|
||||
constructor(rootStore: IRootStore, options: { projectRef: string; connectionString: string }) {
|
||||
|
||||
@@ -55,6 +55,8 @@ export interface Member {
|
||||
primary_email: string
|
||||
username: string
|
||||
}
|
||||
invited_id?: number
|
||||
invited_at?: string
|
||||
}
|
||||
|
||||
export interface ResponseError {
|
||||
|
||||
@@ -160,6 +160,37 @@ Error querying the database: db error: ERROR: prepared statement “s0” alread
|
||||
This is a known issue and it is being worked on, you can follow the progress on this [GitHub issue](https://github.com/prisma/prisma/issues/6485).
|
||||
If you want to learn more about Prisma, check out the [docs](https://www.prisma.io/docs). Also in case you have any questions or run into any issue, feel free to start a discussion in the repo’s [discussions section](https://github.com/prisma/prisma/discussions).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you run `prisma migrate dev --name init` multiple times, it sometimes asks if you want to recreate the whole schema. If you chose yes, it will delete the public schema and recreates it. The default grants are missing after this. If you run into this problem, add a helper SQL for fixing the grants:
|
||||
|
||||
```sql
|
||||
CREATE SCHEMA IF NOT EXISTS "auth";
|
||||
CREATE SCHEMA IF NOT EXISTS "extensions";
|
||||
create extension if not exists "uuid-ossp" with schema extensions;
|
||||
create extension if not exists pgcrypto with schema extensions;
|
||||
create extension if not exists pgjwt with schema extensions;
|
||||
|
||||
grant usage on schema public to postgres, anon, authenticated, service_role;
|
||||
grant usage on schema extensions to postgres, anon, authenticated, service_role;
|
||||
alter user supabase_admin SET search_path TO public, extensions; -- don't include the "auth" schema
|
||||
|
||||
grant all privileges on all tables in schema public to postgres, anon, authenticated, service_role, supabase_admin;
|
||||
grant all privileges on all functions in schema public to postgres, anon, authenticated, service_role, supabase_admin;
|
||||
grant all privileges on all sequences in schema public to postgres, anon, authenticated, service_role, supabase_admin;
|
||||
|
||||
alter default privileges in schema public grant all on tables to postgres, anon, authenticated, service_role;
|
||||
alter default privileges in schema public grant all on functions to postgres, anon, authenticated, service_role;
|
||||
alter default privileges in schema public grant all on sequences to postgres, anon, authenticated, service_role;
|
||||
|
||||
alter default privileges for user supabase_admin in schema public grant all on sequences to postgres, anon, authenticated, service_role;
|
||||
alter default privileges for user supabase_admin in schema public grant all on tables to postgres, anon, authenticated, service_role;
|
||||
alter default privileges for user supabase_admin in schema public grant all on functions to postgres, anon, authenticated, service_role;
|
||||
|
||||
alter role anon set statement_timeout = '3s';
|
||||
alter role authenticated set statement_timeout = '8s';
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [Prisma](https://prisma.io) official website.
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"@kiwicopple/prism-react-renderer": "https://github.com/kiwicopple/prism-react-renderer.git",
|
||||
"@octokit/core": "^3.5.1",
|
||||
"docusaurus-theme-search-typesense": "^0.4.0",
|
||||
"jsrsasign": "^10.4.1",
|
||||
"jsrsasign": "^10.5.25",
|
||||
"mermaid": "^8.13.8",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
@@ -8609,9 +8609,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jsrsasign": {
|
||||
"version": "10.5.24",
|
||||
"resolved": "https://registry.npmjs.org/jsrsasign/-/jsrsasign-10.5.24.tgz",
|
||||
"integrity": "sha512-0i/UHRgJZifp/YmoXHyNQXUY4eKWiSd7YxuD7oKEw9mlqgr51hg9lZQw2nlEDvwHDh7pyj6ZjYlxldlW27xb/Q==",
|
||||
"version": "10.5.25",
|
||||
"resolved": "https://registry.npmjs.org/jsrsasign/-/jsrsasign-10.5.25.tgz",
|
||||
"integrity": "sha512-N7zxHaCwYvFlXsybq4p4RxRwn4AbEq3cEiyjbCrWmwA7g8aS4LTKDJ9AJmsXxwtYesYx0imJ+ITtkyyxLCgeIg==",
|
||||
"funding": {
|
||||
"url": "https://github.com/kjur/jsrsasign#donations"
|
||||
}
|
||||
@@ -20289,9 +20289,9 @@
|
||||
}
|
||||
},
|
||||
"jsrsasign": {
|
||||
"version": "10.5.24",
|
||||
"resolved": "https://registry.npmjs.org/jsrsasign/-/jsrsasign-10.5.24.tgz",
|
||||
"integrity": "sha512-0i/UHRgJZifp/YmoXHyNQXUY4eKWiSd7YxuD7oKEw9mlqgr51hg9lZQw2nlEDvwHDh7pyj6ZjYlxldlW27xb/Q=="
|
||||
"version": "10.5.25",
|
||||
"resolved": "https://registry.npmjs.org/jsrsasign/-/jsrsasign-10.5.25.tgz",
|
||||
"integrity": "sha512-N7zxHaCwYvFlXsybq4p4RxRwn4AbEq3cEiyjbCrWmwA7g8aS4LTKDJ9AJmsXxwtYesYx0imJ+ITtkyyxLCgeIg=="
|
||||
},
|
||||
"keyv": {
|
||||
"version": "3.1.0",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"@kiwicopple/prism-react-renderer": "https://github.com/kiwicopple/prism-react-renderer.git",
|
||||
"@octokit/core": "^3.5.1",
|
||||
"docusaurus-theme-search-typesense": "^0.4.0",
|
||||
"jsrsasign": "^10.4.1",
|
||||
"jsrsasign": "^10.5.25",
|
||||
"mermaid": "^8.13.8",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
|
||||