Skip to content

Commit f7758f9

Browse files
Normalize optional identities and persist GitHub connect state
- Trim/normalize optional author name/email so blank values are handled correctly for auth vs anonymous feedback/comment flows - Persist refreshed GitHub OAuth tokens and compute connected state from persisted or cookie token - Update UI states for GitHub connect button and invitation account switching, with new e2e/unit coverage
1 parent aacad3f commit f7758f9

14 files changed

Lines changed: 365 additions & 31 deletions

File tree

components/products/ProductSettingsGithub.vue

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,13 @@
3131
</div>
3232
<Button variant="outline" data-testid="github-connect" :disabled="isConnecting" @click="connectGithub">
3333
<Icon v-if="isConnecting" name="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
34+
<Icon
35+
v-else-if="isGithubConnected"
36+
name="lucide:check"
37+
class="mr-2 h-4 w-4 text-emerald-600"
38+
/>
3439
<Icon v-else name="lucide:link" class="mr-2 h-4 w-4" />
35-
Connect GitHub
40+
{{ githubConnectLabel }}
3641
</Button>
3742
</div>
3843
</div>
@@ -167,6 +172,12 @@ export default {
167172
canSave() {
168173
return Boolean(this.form.repoFullName)
169174
},
175+
isGithubConnected() {
176+
return Boolean(this.integration?.hasAccessToken || this.$route.query.githubConnected === '1')
177+
},
178+
githubConnectLabel() {
179+
return this.isGithubConnected ? 'Connected' : 'Connect GitHub'
180+
},
170181
repoPlaceholder() {
171182
if (this.isLoadingRepos) return 'Loading repositories...'
172183
if (this.repos.length === 0) return 'Connect GitHub to load repositories'

components/public/PublicFeedbackBoard.vue

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1174,18 +1174,23 @@ export default {
11741174
this.scrollObserver.observe(this.$refs.scrollSentinel)
11751175
},
11761176
async submitFeedback() {
1177-
if (!this.submitForm.title || !this.submitForm.body || !this.submitForm.authorName) return
1177+
const authorName = this.submitForm.authorName.trim()
1178+
const authorEmail = this.submitForm.authorEmail.trim()
1179+
if (!this.submitForm.title || !this.submitForm.body || (!this.currentUser && !authorName)) return
1180+
1181+
const payload = {
1182+
title: this.submitForm.title,
1183+
body: this.submitForm.body,
1184+
categoryId: this.submitForm.categoryId || null,
1185+
...(!this.currentUser && authorName ? { authorName } : {}),
1186+
...(!this.currentUser && authorEmail ? { authorEmail } : {}),
1187+
}
1188+
11781189
this.isSubmitting = true
11791190
try {
11801191
await $fetch(`/api/public/t/${this.teamSlug}/${this.projectSlug}/feedback`, {
11811192
method: 'POST',
1182-
body: {
1183-
title: this.submitForm.title,
1184-
body: this.submitForm.body,
1185-
categoryId: this.submitForm.categoryId || null,
1186-
authorName: this.submitForm.authorName,
1187-
authorEmail: this.submitForm.authorEmail || undefined,
1188-
},
1193+
body: payload,
11891194
})
11901195
this.showSubmitDialog = false
11911196
this.submitForm = { title: '', body: '', categoryId: '', authorName: '', authorEmail: '' }
@@ -1455,7 +1460,7 @@ export default {
14551460
openCommentOptions() {
14561461
if (!this.detailCommentBody.trim()) return
14571462
if (this.currentUser) {
1458-
this.executeCommentSubmit('', '')
1463+
this.executeCommentSubmit()
14591464
return
14601465
}
14611466
this.commentOptionSelected = null
@@ -1471,16 +1476,18 @@ export default {
14711476
await this.executeCommentSubmit(this.commentOptionName.trim(), this.commentOptionEmail.trim())
14721477
},
14731478
async executeCommentSubmit(authorName, authorEmail) {
1479+
const payload = {
1480+
body: this.detailCommentBody.trim(),
1481+
...(authorName?.trim() ? { authorName: authorName.trim() } : {}),
1482+
...(authorEmail?.trim() ? { authorEmail: authorEmail.trim() } : {}),
1483+
}
1484+
14741485
this.isSubmittingDetailComment = true
14751486
this.detailCommentError = null
14761487
try {
14771488
const response = await $fetch(`/api/feedback/${this.selectedFeedbackId}/comments`, {
14781489
method: 'POST',
1479-
body: {
1480-
body: this.detailCommentBody.trim(),
1481-
authorName,
1482-
authorEmail: authorEmail || undefined,
1483-
},
1490+
body: payload,
14841491
})
14851492
const comment = response?.data
14861493
if (comment) {

pages/accept-invitation/index.vue

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,29 @@
2222
</div>
2323

2424
<template v-else>
25-
<Alert v-if="errorMessage" variant="destructive">
26-
<Icon name="lucide:circle-alert" class="h-4 w-4" />
27-
<AlertTitle>Invitation unavailable</AlertTitle>
28-
<AlertDescription>{{ errorMessage }}</AlertDescription>
29-
</Alert>
25+
<template v-if="errorMessage">
26+
<Alert variant="destructive">
27+
<Icon name="lucide:circle-alert" class="h-4 w-4" />
28+
<AlertTitle>Invitation unavailable</AlertTitle>
29+
<AlertDescription>{{ errorMessage }}</AlertDescription>
30+
</Alert>
31+
32+
<template v-if="session?.user">
33+
<div class="rounded-lg border bg-muted/40 p-4 text-sm text-muted-foreground">
34+
You're signed in as <span class="font-medium text-foreground">{{ session.user.email }}</span>. Try
35+
signing in with the email address this invitation was sent to.
36+
</div>
37+
<div class="grid gap-3">
38+
<Button class="w-full" :disabled="isSwitching" @click="switchAccount">
39+
<Icon v-if="isSwitching" name="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
40+
Sign in with a different account
41+
</Button>
42+
<Button as-child variant="outline" class="w-full">
43+
<NuxtLink :to="signupLink">Create a new account</NuxtLink>
44+
</Button>
45+
</div>
46+
</template>
47+
</template>
3048

3149
<div v-else-if="wasAccepted" class="space-y-4 text-center">
3250
<p class="text-sm text-muted-foreground">
@@ -83,6 +101,7 @@ export default {
83101
session: null,
84102
isLoading: true,
85103
isAccepting: false,
104+
isSwitching: false,
86105
wasAccepted: false,
87106
errorMessage: '',
88107
}
@@ -177,6 +196,16 @@ export default {
177196
}
178197
},
179198
199+
async switchAccount() {
200+
try {
201+
this.isSwitching = true
202+
await authClient.signOut()
203+
await navigateTo(this.loginLink)
204+
} catch {
205+
this.isSwitching = false
206+
}
207+
},
208+
180209
async goToWorkspace() {
181210
await navigateTo('/dashboard')
182211
},

server/api/auth/callback/github/integration.get.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { persistGithubIntegrationAccessToken } from '~/server/utils/github-integration'
12
import { createErrorResponse, ErrorCode } from '~/server/utils/response'
23
import { resolveGithubIntegrationCallbackUrl } from '~/server/utils/github-oauth'
34
import { requireProjectCategoryAccessBySlug } from '~/server/utils/project-categories'
@@ -44,7 +45,7 @@ export default defineEventHandler(async (event) => {
4445
})
4546
}
4647

47-
await requireProjectCategoryAccessBySlug(event, stateCookie.projectSlug)
48+
const { project } = await requireProjectCategoryAccessBySlug(event, stateCookie.projectSlug)
4849

4950
const clientId = process.env.GITHUB_CLIENT_ID
5051
const clientSecret = process.env.GITHUB_CLIENT_SECRET
@@ -97,6 +98,8 @@ export default defineEventHandler(async (event) => {
9798
path: '/',
9899
})
99100

101+
await persistGithubIntegrationAccessToken(project.id, tokenBody.access_token)
102+
100103
setCookie(event, 'veerify_github_oauth_token', tokenBody.access_token, {
101104
httpOnly: true,
102105
secure: process.env.NODE_ENV === 'production',

server/api/feedback/[id]/comments.post.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { createErrorResponse, createSuccessResponse, ErrorCode } from '~/server/
44
import { optionalAuth } from '~/server/utils/auth-middleware'
55
import { requirePublicProject, requireProjectAccess } from '~/server/utils/project-access'
66
import { getOrCreateAnonSession } from '~/server/utils/anonymous-session'
7-
import { validateBody } from '~/server/utils/validation'
7+
import { validateBody, optionalTrimmedEmail, optionalTrimmedString } from '~/server/utils/validation'
88
import { requireRateLimit, rateLimits } from '~/server/utils/rate-limit'
99
import { db } from '~/server/database/drizzle'
1010
import { feedback, feedbackComment, feedbackSubscription } from '~/server/database/schema/feedback'
@@ -15,8 +15,8 @@ const createCommentSchema = z.object({
1515
body: z.string().min(1, 'Comment body is required').max(5000, 'Comment too long'),
1616
parentCommentId: z.string().optional().nullable(),
1717
isInternal: z.boolean().optional().default(false),
18-
authorName: z.string().min(1).max(100).optional(),
19-
authorEmail: z.string().email().optional(),
18+
authorName: optionalTrimmedString(100),
19+
authorEmail: optionalTrimmedEmail(),
2020
})
2121

2222
export default defineEventHandler(async (event) => {

server/api/feedback/index.post.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { eq } from 'drizzle-orm'
33
import { createErrorResponse, createSuccessResponse, ErrorCode } from '~/server/utils/response'
44
import { optionalAuth } from '~/server/utils/auth-middleware'
55
import { getOrCreateAnonSession } from '~/server/utils/anonymous-session'
6-
import { validateBody } from '~/server/utils/validation'
6+
import { validateBody, optionalTrimmedEmail, optionalTrimmedString } from '~/server/utils/validation'
77
import { requirePublicProject, requireProjectAccess } from '~/server/utils/project-access'
88
import { requireRateLimit, rateLimits } from '~/server/utils/rate-limit'
99
import { db } from '~/server/database/drizzle'
@@ -23,8 +23,8 @@ const createFeedbackSchema = z.object({
2323
projectId: z.string().min(1, 'Project ID is required'),
2424
categoryId: z.string().optional().nullable(),
2525
feedbackType: z.enum(['feature_request', 'bug_report', 'improvement', 'question', 'other']).optional(),
26-
authorName: z.string().min(1).max(100).optional(),
27-
authorEmail: z.string().email().optional(),
26+
authorName: optionalTrimmedString(100),
27+
authorEmail: optionalTrimmedEmail(),
2828
})
2929

3030
function createGitHubHeaders(accessToken: string) {

server/api/projects/[slug]/github.get.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { eq } from 'drizzle-orm'
22
import { db } from '~/server/database/drizzle'
33
import { githubIntegration } from '~/server/database/schema/feedback'
4+
import { hasGithubIntegrationAccessToken } from '~/server/utils/github-integration'
45
import { createSuccessResponse } from '~/server/utils/response'
56
import { requireProjectCategoryAccess } from '~/server/utils/project-categories'
67

@@ -13,6 +14,8 @@ export default defineEventHandler(async (event) => {
1314
.where(eq(githubIntegration.projectId, project.id))
1415
.limit(1)
1516

17+
const oauthToken = getCookie(event, 'veerify_github_oauth_token')
18+
1619
if (!integration) {
1720
return createSuccessResponse(null)
1821
}
@@ -25,7 +28,10 @@ export default defineEventHandler(async (event) => {
2528
syncEnabled: integration.syncEnabled,
2629
autoCreateIssues: integration.autoCreateIssues,
2730
autoSyncStatus: integration.autoSyncStatus,
28-
hasAccessToken: Boolean(integration.accessToken),
31+
hasAccessToken: hasGithubIntegrationAccessToken({
32+
persistedAccessToken: integration.accessToken,
33+
oauthToken,
34+
}),
2935
updatedAt: integration.updatedAt,
3036
})
3137
})

server/api/public/t/[teamSlug]/[projectSlug]/feedback.post.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { eq, and } from 'drizzle-orm'
33
import { createSuccessResponse } from '~/server/utils/response'
44
import { optionalAuth } from '~/server/utils/auth-middleware'
55
import { getOrCreateAnonSession } from '~/server/utils/anonymous-session'
6-
import { validateBody } from '~/server/utils/validation'
6+
import { validateBody, optionalTrimmedEmail, optionalTrimmedString } from '~/server/utils/validation'
77
import { resolvePublicProjectByTeam } from '~/server/utils/project-access'
88
import { createErrorResponse, ErrorCode } from '~/server/utils/response'
99
import { requireRateLimit, rateLimits } from '~/server/utils/rate-limit'
@@ -14,8 +14,8 @@ const submitFeedbackSchema = z.object({
1414
title: z.string().min(1, 'Title is required').max(200, 'Title too long'),
1515
body: z.string().min(1, 'Description is required').max(5000, 'Description too long'),
1616
categoryId: z.string().optional().nullable(),
17-
authorName: z.string().min(1, 'Name is required').max(100).optional(),
18-
authorEmail: z.string().email('Invalid email').optional(),
17+
authorName: optionalTrimmedString(100, 'Name too long'),
18+
authorEmail: optionalTrimmedEmail('Invalid email'),
1919
})
2020

2121
export default defineEventHandler(async (event) => {

server/utils/github-integration.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { eq } from 'drizzle-orm'
2+
import { db } from '../database/drizzle'
3+
import { githubIntegration } from '../database/schema/feedback'
4+
5+
type GithubIntegrationUpdateClient = Pick<typeof db, 'update'>
6+
7+
export function hasGithubIntegrationAccessToken(params: {
8+
persistedAccessToken?: string | null
9+
oauthToken?: string | null
10+
}) {
11+
return Boolean(params.oauthToken || params.persistedAccessToken)
12+
}
13+
14+
export async function persistGithubIntegrationAccessToken(
15+
projectId: string,
16+
accessToken: string,
17+
updatedAt: Date = new Date(),
18+
database: GithubIntegrationUpdateClient = db
19+
) {
20+
if (!projectId || !accessToken) {
21+
return false
22+
}
23+
24+
const updated = await database
25+
.update(githubIntegration)
26+
.set({
27+
accessToken,
28+
updatedAt,
29+
})
30+
.where(eq(githubIntegration.projectId, projectId))
31+
.returning({ id: githubIntegration.id })
32+
33+
return updated.length > 0
34+
}

server/utils/validation.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,29 @@ import { z } from 'zod'
77
import type { H3Event } from 'h3'
88
import { ErrorCode, createErrorResponse } from './response'
99

10+
function normalizeOptionalStringValue(value: unknown) {
11+
if (typeof value !== 'string') {
12+
return value
13+
}
14+
15+
const trimmed = value.trim()
16+
return trimmed.length > 0 ? trimmed : undefined
17+
}
18+
19+
export function optionalTrimmedString(maxLength: number, tooLongMessage?: string) {
20+
return z.preprocess(
21+
normalizeOptionalStringValue,
22+
z.string().max(maxLength, tooLongMessage || `Must be ${maxLength} characters or fewer`).optional()
23+
)
24+
}
25+
26+
export function optionalTrimmedEmail(invalidMessage?: string) {
27+
return z.preprocess(
28+
normalizeOptionalStringValue,
29+
z.string().email(invalidMessage || 'Invalid email format').optional()
30+
)
31+
}
32+
1033
/**
1134
* Validates request body against a Zod schema
1235
* @param event - H3 event

0 commit comments

Comments
 (0)