Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions app/libs/auth.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { db, dialect } from '~/app/services/db.server'
import { linkGithubUserToCompanyUsers } from '~/app/services/github-linking.server'
import { getTenantDb } from '~/app/services/tenant-db.server'
import type { OrganizationId } from '~/app/types/organization'
import type { MemberRole } from './member-role'
import { isOrgOwner, type MemberRole } from './member-role'
import { RESERVED_SLUGS } from './reserved-slugs'

export const auth = betterAuth({
Expand Down Expand Up @@ -362,7 +362,7 @@ export const isReservedSlug = (slug: string): boolean => {
return RESERVED_SLUGS.has(slug.toLowerCase())
}

export { isOrgAdmin } from './member-role'
export { isOrgAdmin, isOrgOwner } from './member-role'
export type { MemberRole } from './member-role'

export interface OrgContext {
Expand Down Expand Up @@ -413,6 +413,15 @@ export const requireOrgMember = async (
}
}

export const requireOrgOwner = (
membership: { role: MemberRole },
orgSlug: string,
): void => {
if (!isOrgOwner(membership.role)) {
throw redirect(href('/:orgSlug/settings/repositories', { orgSlug }))
}
}

export const getUserOrganizations = async (userId: string) => {
return await db
.selectFrom('members')
Expand Down
16 changes: 16 additions & 0 deletions app/libs/member-role.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { describe, expect, test } from 'vitest'
import { isOrgAdmin, isOrgOwner } from './member-role'

describe('member-role', () => {
test('isOrgOwner is true only for owner', () => {
expect(isOrgOwner('owner')).toBe(true)
expect(isOrgOwner('admin')).toBe(false)
expect(isOrgOwner('member')).toBe(false)
})

test('isOrgAdmin includes owner and admin', () => {
expect(isOrgAdmin('owner')).toBe(true)
expect(isOrgAdmin('admin')).toBe(true)
expect(isOrgAdmin('member')).toBe(false)
})
})
2 changes: 2 additions & 0 deletions app/libs/member-role.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@ export type MemberRole = Members['role']

export const isOrgAdmin = (role: MemberRole): boolean =>
role === 'owner' || role === 'admin'

export const isOrgOwner = (role: MemberRole): boolean => role === 'owner'
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,13 @@ export function MemberRowActions({
<input type="hidden" name="memberId" value={member.id} />
</ConfirmDialog>

<ChangeRoleDialog
open={roleOpen}
onOpenChange={setRoleOpen}
member={member}
/>
{roleOpen && (
<ChangeRoleDialog
open={roleOpen}
onOpenChange={setRoleOpen}
member={member}
/>
)}
</>
)
}
Expand Down Expand Up @@ -127,15 +129,6 @@ function ChangeRoleDialog({
}
}, [fetcher.state, fetcher.data, onOpenChange])

// Reset form and fetcher when dialog opens
// biome-ignore lint/correctness/useExhaustiveDependencies: form and fetcher are unstable references from hooks, but we only want this to run when open changes
useEffect(() => {
if (open) {
form.reset()
fetcher.reset()
}
}, [open])

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,14 @@ import type { TeamRow } from '../../teams._index/queries.server'
interface DataTableToolbarProps {
teams: TeamRow[]
orgSlug: string
canAddRepositories: boolean
}

export function DataTableToolbar({ teams, orgSlug }: DataTableToolbarProps) {
export function DataTableToolbar({
teams,
orgSlug,
canAddRepositories,
}: DataTableToolbarProps) {
const { queries, updateQueries, isFiltered, resetFilters } =
useDataTableState()

Expand All @@ -38,12 +43,14 @@ export function DataTableToolbar({ teams, orgSlug }: DataTableToolbarProps) {
</Button>
)}
</div>
<Button asChild>
<Link to={href('/:orgSlug/settings/repositories/add', { orgSlug })}>
<PlusIcon className="h-4 w-4" />
Add
</Link>
</Button>
{canAddRepositories ? (
<Button asChild>
<Link to={href('/:orgSlug/settings/repositories/add', { orgSlug })}>
<PlusIcon className="h-4 w-4" />
Add
</Link>
</Button>
) : null}
</div>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ interface DataTableProps {
pagination: PaginationProps
teams: TeamRow[]
orgSlug: string
canAddRepositories: boolean
}

export function RepoTable({
Expand All @@ -45,6 +46,7 @@ export function RepoTable({
pagination,
teams,
orgSlug,
canAddRepositories,
}: DataTableProps) {
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
Expand All @@ -62,7 +64,11 @@ export function RepoTable({

return (
<div className="space-y-4">
<DataTableToolbar teams={teams} orgSlug={orgSlug} />
<DataTableToolbar
teams={teams}
orgSlug={orgSlug}
canAddRepositories={canAddRepositories}
/>
<div className="rounded-md border">
<Table>
<TableHeader>
Expand Down
20 changes: 17 additions & 3 deletions app/routes/$orgSlug/settings/repositories._index/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { data } from 'react-router'
import { dataWithError, dataWithSuccess } from 'remix-toast'
import { match } from 'ts-pattern'
import { z } from 'zod'
import { isOrgOwner } from '~/app/libs/auth.server'
import { getErrorMessage } from '~/app/libs/error-message'
import { orgContext } from '~/app/middleware/context'
import ContentSection from '../+components/content-section'
Expand All @@ -23,7 +24,7 @@ import {
import { listFilteredRepositories } from './queries.server'

export const loader = async ({ request, context }: Route.LoaderArgs) => {
const { organization } = context.get(orgContext)
const { organization, membership } = context.get(orgContext)
const searchParams = new URL(request.url).searchParams

const { repo, team } = QuerySchema.parse({
Expand Down Expand Up @@ -53,7 +54,13 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => {

const teams = await listTeams(organization.id)

return { organization, repositories, pagination, teams }
return {
organization,
repositories,
pagination,
teams,
canAddRepositories: isOrgOwner(membership.role),
}
}

const nullableTeamId = z.preprocess(
Expand Down Expand Up @@ -129,7 +136,13 @@ export const action = async ({ request, context }: Route.ActionArgs) => {
}

export default function OrganizationRepositoryIndexPage({
loaderData: { organization, repositories, pagination, teams },
loaderData: {
organization,
repositories,
pagination,
teams,
canAddRepositories,
},
}: Route.ComponentProps) {
const slug = organization.slug
const columns = useMemo(() => createColumns(slug, teams), [slug, teams])
Expand All @@ -146,6 +159,7 @@ export default function OrganizationRepositoryIndexPage({
pagination={pagination}
teams={teams}
orgSlug={slug}
canAddRepositories={canAddRepositories}
/>
</ContentSection>
)
Expand Down
7 changes: 5 additions & 2 deletions app/routes/$orgSlug/settings/repositories.add/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
SelectValue,
Stack,
} from '~/app/components/ui'
import { requireOrgOwner } from '~/app/libs/auth.server'
import { orgContext } from '~/app/middleware/context'
import { clearOrgCache, getOrgCachedData } from '~/app/services/cache.server'
import ContentSection from '../+components/content-section'
Expand All @@ -45,7 +46,8 @@ export const loader = async ({
params,
context,
}: Route.LoaderArgs) => {
const { organization } = context.get(orgContext)
const { organization, membership } = context.get(orgContext)
requireOrgOwner(membership, organization.slug)
const { owner, cursor, query, refresh } = zx.parseQuery(request, {
owner: z.string().optional(),
cursor: z.string().optional(),
Expand Down Expand Up @@ -111,7 +113,8 @@ export const loader = async ({
}

export const action = async ({ request, context }: Route.ActionArgs) => {
const { organization } = context.get(orgContext)
const { organization, membership } = context.get(orgContext)
requireOrgOwner(membership, organization.slug)
const integration = await getIntegration(organization.id)
if (!integration) {
throw new Error('integration not created')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
SelectValue,
Stack,
} from '~/app/components/ui'
import { isOrgOwner, requireOrgOwner } from '~/app/libs/auth.server'
import { getErrorMessage } from '~/app/libs/error-message'
import { orgContext } from '~/app/middleware/context'
import ContentSection from '../../../+components/content-section'
Expand All @@ -54,7 +55,7 @@ const githubSchema = z.object({
})

export const loader = async ({ params, context }: Route.LoaderArgs) => {
const { organization } = context.get(orgContext)
const { organization, membership } = context.get(orgContext)
const { repository: repositoryId } = zx.parseParams(params, {
repository: z.string(),
})
Expand All @@ -72,6 +73,7 @@ export const loader = async ({ params, context }: Route.LoaderArgs) => {
repositoryId,
repository,
provider: integration.provider,
canDeleteRepository: isOrgOwner(membership.role),
}
}

Expand All @@ -98,7 +100,7 @@ export const action = async ({
params,
context,
}: Route.ActionArgs) => {
const { organization } = context.get(orgContext)
const { organization, membership } = context.get(orgContext)
const { repository: repositoryId } = zx.parseParams(params, {
repository: z.string(),
})
Expand All @@ -107,6 +109,11 @@ export const action = async ({
throw new Response('repository not found', { status: 404 })
}
const formData = await request.formData()
// update (settings change) is allowed for all roles; delete requires owner
const intent = formData.get('intent')
if (intent !== 'update') {
requireOrgOwner(membership, organization.slug)
}
const submission = parseWithZod(formData, { schema: actionSchema })
if (submission.status !== 'success') {
return data({ ok: false, lastResult: submission.reply() }, { status: 400 })
Expand Down Expand Up @@ -288,7 +295,7 @@ function DangerZone({
}

export default function EditRepositoryPage({
loaderData: { organization, repository, provider },
loaderData: { organization, repository, provider, canDeleteRepository },
}: Route.ComponentProps) {
const form = match(provider)
.with('github', () => (
Expand All @@ -308,7 +315,7 @@ export default function EditRepositoryPage({
{form}
</ContentSection>

<DangerZone repository={repository} />
{canDeleteRepository ? <DangerZone repository={repository} /> : null}
</Stack>
)
}
Loading