parent
be2cfc8bfd
commit
56602612c8
|
|
@ -146,5 +146,5 @@ See [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) and [`deploy/k8s.yaml`](deploy/k8
|
||||||
|
|
||||||
Management endpoints require a bearer JWT. In local-password mode change the default `admin` password and set a stable `localTokenSecret`;
|
Management endpoints require a bearer JWT. In local-password mode change the default `admin` password and set a stable `localTokenSecret`;
|
||||||
in OIDC mode enforce MFA and role assignment in the identity provider. Use HTTPS in production, keep `corsOrigins` narrow,
|
in OIDC mode enforce MFA and role assignment in the identity provider. Use HTTPS in production, keep `corsOrigins` narrow,
|
||||||
and enable `requireSubmissionAuth` if forms must not be publicly accessible. Access codes protect
|
and enable `requireSubmissionAuth` if forms must not be publicly accessible. Access codes protect
|
||||||
individual form submissions, not administration.
|
individual form submissions, not administration.
|
||||||
|
|
|
||||||
|
|
@ -51,10 +51,11 @@ const (
|
||||||
|
|
||||||
// Defines values for UserIdType.
|
// Defines values for UserIdType.
|
||||||
const (
|
const (
|
||||||
Email UserIdType = "email"
|
Email UserIdType = "email"
|
||||||
Guid UserIdType = "guid"
|
Guid UserIdType = "guid"
|
||||||
Phone UserIdType = "phone"
|
Phone UserIdType = "phone"
|
||||||
Username UserIdType = "username"
|
Username UserIdType = "username"
|
||||||
|
ForeignKey UserIdType = "foreignkey"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AccessCode defines model for AccessCode.
|
// AccessCode defines model for AccessCode.
|
||||||
|
|
@ -382,7 +383,7 @@ type SubmitFormRequest struct {
|
||||||
// Tracking Client supplied tracking details. IP and user agent are captured server-side.
|
// Tracking Client supplied tracking details. IP and user agent are captured server-side.
|
||||||
Tracking *ClientTracking `json:"tracking,omitempty"`
|
Tracking *ClientTracking `json:"tracking,omitempty"`
|
||||||
|
|
||||||
// UserId Respondent identifier captured at the start (email, username, phone or GUID)
|
// UserId Respondent identifier captured at the start (email, username, phone, foreignkey or GUID)
|
||||||
UserId string `json:"userId"`
|
UserId string `json:"userId"`
|
||||||
UserIdType UserIdType `json:"userIdType"`
|
UserIdType UserIdType `json:"userIdType"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -449,7 +449,7 @@ func (s *Server) SubmitForm(ctx context.Context, req SubmitFormRequestObject) (S
|
||||||
|
|
||||||
userIdType := UserIdType(strings.ToLower(strings.TrimSpace(string(req.Body.UserIdType))))
|
userIdType := UserIdType(strings.ToLower(strings.TrimSpace(string(req.Body.UserIdType))))
|
||||||
if !validUserIdType(userIdType) {
|
if !validUserIdType(userIdType) {
|
||||||
return SubmitForm400JSONResponse{ErrorJSONResponse: errJSON("userIdType must be one of email, username, phone or guid", "validation_error")}, nil
|
return SubmitForm400JSONResponse{ErrorJSONResponse: errJSON("userIdType must be one of email, username, phone, foreign key or guid", "validation_error")}, nil
|
||||||
}
|
}
|
||||||
userId := strings.TrimSpace(req.Body.UserId)
|
userId := strings.TrimSpace(req.Body.UserId)
|
||||||
if userId == "" {
|
if userId == "" {
|
||||||
|
|
@ -1045,7 +1045,7 @@ func toFloat(v interface{}) (float64, bool) {
|
||||||
|
|
||||||
func validUserIdType(t UserIdType) bool {
|
func validUserIdType(t UserIdType) bool {
|
||||||
switch t {
|
switch t {
|
||||||
case Email, Username, Phone, Guid:
|
case Email, Username, Phone, ForeignKey, Guid:
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
|
|
||||||
|
|
@ -183,7 +183,7 @@ func renderSubmitterDetails(doc *fpdf.Fpdf, form *database.Form) {
|
||||||
doc.SetXY(pdfLeftMargin, y)
|
doc.SetXY(pdfLeftMargin, y)
|
||||||
doc.CellFormat(26, 6, "Identifier type:", "", 0, "L", false, 0, "")
|
doc.CellFormat(26, 6, "Identifier type:", "", 0, "L", false, 0, "")
|
||||||
x := pdfLeftMargin + 28
|
x := pdfLeftMargin + 28
|
||||||
for _, label := range []string{"Email", "Username", "Phone", "GUID"} {
|
for _, label := range []string{"Email", "Username", "Phone", "ForeignKey", "GUID"} {
|
||||||
drawCheckbox(doc, x, y+1)
|
drawCheckbox(doc, x, y+1)
|
||||||
doc.SetXY(x+5, y)
|
doc.SetXY(x+5, y)
|
||||||
doc.CellFormat(0, 6, label, "", 0, "L", false, 0, "")
|
doc.CellFormat(0, 6, label, "", 0, "L", false, 0, "")
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,13 @@ info:
|
||||||
description: |
|
description: |
|
||||||
API for **Kererū forms**, a simple survey/form capture system.
|
API for **Kererū forms**, a simple survey/form capture system.
|
||||||
|
|
||||||
Forms are versioned and can be inactivated. A form contains an ordered set of
|
Forms are versioned and can be inactivated.
|
||||||
|
|
||||||
|
A form contains an ordered set of
|
||||||
questions whose answers are one of: true/false, numeric, a drop-down selection,
|
questions whose answers are one of: true/false, numeric, a drop-down selection,
|
||||||
or free-form text. Responses are identified by a user identifier captured at the
|
date or free-form text.
|
||||||
|
|
||||||
|
Responses are identified by a user identifier captured at the
|
||||||
start of the form. A form may optionally require a single-use access code.
|
start of the form. A form may optionally require a single-use access code.
|
||||||
contact:
|
contact:
|
||||||
name: Kererū Forms
|
name: Kererū Forms
|
||||||
|
|
@ -806,7 +810,7 @@ components:
|
||||||
$ref: '#/components/schemas/QuestionInput'
|
$ref: '#/components/schemas/QuestionInput'
|
||||||
UserIdType:
|
UserIdType:
|
||||||
type: string
|
type: string
|
||||||
enum: [email, username, phone, guid]
|
enum: [email, username, phone, foreignkey guid]
|
||||||
Answer:
|
Answer:
|
||||||
type: object
|
type: object
|
||||||
required: [questionId, value]
|
required: [questionId, value]
|
||||||
|
|
@ -822,7 +826,7 @@ components:
|
||||||
properties:
|
properties:
|
||||||
userId:
|
userId:
|
||||||
type: string
|
type: string
|
||||||
description: Respondent identifier captured at the start (email, username, phone or GUID)
|
description: Respondent identifier captured at the start (email, username, phone, foreignkey or GUID)
|
||||||
userIdType:
|
userIdType:
|
||||||
$ref: '#/components/schemas/UserIdType'
|
$ref: '#/components/schemas/UserIdType'
|
||||||
accessCode:
|
accessCode:
|
||||||
|
|
|
||||||
|
|
@ -82,8 +82,8 @@ the question along with the answwer.
|
||||||
## Database Schema
|
## Database Schema
|
||||||
|
|
||||||
The schema is to allow for multiple forms and capture the user input
|
The schema is to allow for multiple forms and capture the user input
|
||||||
using an user identifier such as email, username, international phine number or GUID.
|
using an user identifier such as email, username, international phone number,
|
||||||
The identifier is captured at the start.
|
foreign key or GUID. The identifier is captured at the start.
|
||||||
|
|
||||||
Forms can be versioned and include a logo and company name plus contact details that is
|
Forms can be versioned and include a logo and company name plus contact details that is
|
||||||
linked to the form. Existing forms can be edited which will generate a new version number
|
linked to the form. Existing forms can be edited which will generate a new version number
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,7 @@ All timestamps are stored as RFC 3339 UTC strings. IDs are UUIDs (TEXT).
|
||||||
|
|
||||||
Form definitions: non-empty title, unique keys (`[A-Za-z][A-Za-z0-9_-]*`), supported question types, select questions require unique non-empty option values, numeric bounds ordered.
|
Form definitions: non-empty title, unique keys (`[A-Za-z][A-Za-z0-9_-]*`), supported question types, select questions require unique non-empty option values, numeric bounds ordered.
|
||||||
|
|
||||||
Submissions: identifier type must be `email|username|phone|guid`; email identifiers are parsed; answers must reference questions of that form; duplicates rejected; required questions enforced; type checks for boolean/number/select/text; numeric bounds; select values must exist; text length capped and single-line questions reject newlines.
|
Submissions: identifier type must be `email|username|phone|foreignkey|guid`; email identifiers are parsed; answers must reference questions of that form; duplicates rejected; required questions enforced; type checks for boolean/number/select/text; numeric bounds; select values must exist; text length capped and single-line questions reject newlines.
|
||||||
|
|
||||||
Access codes are consumed in the same SQL transaction as the submission insert. The conditional `UPDATE ... WHERE used = 0` makes reuse safe under concurrency; failure rolls back the submission.
|
Access codes are consumed in the same SQL transaction as the submission insert. The conditional `UPDATE ... WHERE used = 0` makes reuse safe under concurrency; failure rolls back the submission.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,15 +33,15 @@ export function QuestionField({ question, value, onChange }: QuestionFieldProps)
|
||||||
|
|
||||||
if (question.type === 'boolean') {
|
if (question.type === 'boolean') {
|
||||||
control = (
|
control = (
|
||||||
<div id={id} className="flex gap-6 pt-1" role="radiogroup" aria-label={question.label}>
|
<div id={id} className="flex gap-6 pt-1" role="checkboxgroup" aria-label={question.label}>
|
||||||
{[
|
{[
|
||||||
{ label: 'True', checked: value === true },
|
{ label: 'True', checked: value === true },
|
||||||
{ label: 'False', checked: value === false },
|
{ label: 'False', checked: value === false },
|
||||||
].map((option) => (
|
].map((option) => (
|
||||||
<label key={option.label} className="flex items-center gap-2 text-sm">
|
<label key={option.label} className="flex items-center gap-2 text-sm">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="checkbox"
|
||||||
name={`radio-${question.id}`}
|
name={`checkbox-${question.id}`}
|
||||||
checked={option.checked}
|
checked={option.checked}
|
||||||
onChange={() => onChange(option.label === 'True')}
|
onChange={() => onChange(option.label === 'True')}
|
||||||
className="h-4 w-4 accent-brand-600"
|
className="h-4 w-4 accent-brand-600"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
export type QuestionType = 'boolean' | 'date' | 'number' | 'select' | 'text'
|
export type QuestionType = 'boolean' | 'date' | 'number' | 'select' | 'text'
|
||||||
|
|
||||||
export type UserIdType = 'email' | 'username' | 'phone' | 'guid'
|
export type UserIdType = 'email' | 'username' | 'phone' | 'foreignkey' | 'guid'
|
||||||
|
|
||||||
export type SignatureType = 'drawn' | 'image'
|
export type SignatureType = 'drawn' | 'image'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ const userIdTypes: Array<{ value: UserIdType; label: string }> = [
|
||||||
{ value: 'email', label: 'Email address' },
|
{ value: 'email', label: 'Email address' },
|
||||||
{ value: 'username', label: 'Username' },
|
{ value: 'username', label: 'Username' },
|
||||||
{ value: 'phone', label: 'Phone number' },
|
{ value: 'phone', label: 'Phone number' },
|
||||||
|
{ value: 'foreignkey', label: 'Provided key' },
|
||||||
{ value: 'guid', label: 'GUID' },
|
{ value: 'guid', label: 'GUID' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -64,6 +65,15 @@ export function FillFormPage() {
|
||||||
const [submission, setSubmission] = useState<Submission | null>(null)
|
const [submission, setSubmission] = useState<Submission | null>(null)
|
||||||
const [downloadingPdf, setDownloadingPdf] = useState(false)
|
const [downloadingPdf, setDownloadingPdf] = useState(false)
|
||||||
const [pdfError, setPdfError] = useState<string | null>(null)
|
const [pdfError, setPdfError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Map option values to preferred placeholder texts
|
||||||
|
const placeholders = {
|
||||||
|
email: "Enter your email (e.g., name@example.com)",
|
||||||
|
phone: "Enter your phone number (e.g., +61403555012)",
|
||||||
|
foreignkey: "Use your supplied key",
|
||||||
|
username: "Enter your handle (e.g., @username)",
|
||||||
|
guid: "Enter your identifier"
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (authLoading) return
|
if (authLoading) return
|
||||||
|
|
@ -390,7 +400,7 @@ export function FillFormPage() {
|
||||||
<legend className="label">How would you like to respond?</legend>
|
<legend className="label">How would you like to respond?</legend>
|
||||||
<label className="flex items-start gap-2 text-sm">
|
<label className="flex items-start gap-2 text-sm">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="checkbox"
|
||||||
name="completionMode"
|
name="completionMode"
|
||||||
checked={completionMode === 'online'}
|
checked={completionMode === 'online'}
|
||||||
onChange={() => setCompletionMode('online')}
|
onChange={() => setCompletionMode('online')}
|
||||||
|
|
@ -405,7 +415,7 @@ export function FillFormPage() {
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-start gap-2 text-sm">
|
<label className="flex items-start gap-2 text-sm">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="checkbox"
|
||||||
name="completionMode"
|
name="completionMode"
|
||||||
checked={completionMode === 'offline'}
|
checked={completionMode === 'offline'}
|
||||||
onChange={() => setCompletionMode('offline')}
|
onChange={() => setCompletionMode('offline')}
|
||||||
|
|
@ -466,7 +476,7 @@ export function FillFormPage() {
|
||||||
className="input"
|
className="input"
|
||||||
value={userId}
|
value={userId}
|
||||||
onChange={(event) => setUserId(event.target.value)}
|
onChange={(event) => setUserId(event.target.value)}
|
||||||
placeholder={userIdType === 'email' ? 'you@example.com' : 'Enter your identifier'}
|
placeholder={placeholders[userIdType] || 'Enter your identifier'} //placeholder={userIdType === 'email' ? 'you@example.com' : 'Enter your identifier'}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -42,24 +42,28 @@ export function HomePage() {
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<section className="card bg-gradient-to-br from-brand-600 to-brand-800 text-white dark:from-brand-800 dark:to-brand-950">
|
<section className="card bg-gradient-to-br from-brand-600 to-brand-800 text-white dark:from-brand-800 dark:to-brand-950">
|
||||||
<p className="text-sm font-semibold uppercase tracking-widest text-brand-100">Kererū Forms</p>
|
<p className="text-sm font-semibold uppercase tracking-widest text-brand-100">Kererū Forms</p>
|
||||||
<h1 className="mt-2 text-3xl font-bold sm:text-4xl">Simple, versioned surveys</h1>
|
<h1 className="mt-2 text-3xl font-bold sm:text-4xl">Simple, versioned forms</h1>
|
||||||
<p className="mt-2 max-w-2xl text-brand-50">
|
<p className="mt-2 max-w-2xl text-brand-50">
|
||||||
Pick a form below to share your feedback. Forms are versioned, so your previous answers are
|
Pick a form below to complete. Forms can also be thought of as surveys.
|
||||||
never shown again.
|
|
||||||
</p>
|
</p>
|
||||||
{(user?.canAdmin || user?.canView) && (
|
{(user?.canAdmin || user?.canView) && (
|
||||||
<div className="mt-5 flex flex-wrap gap-3">
|
<div>
|
||||||
<Link to="/manage" className="btn bg-white text-brand-800 hover:bg-brand-50">
|
<p className="mt-2 max-w-2xl text-brand-50">
|
||||||
Manage forms
|
Use the buttons below to manage forms or view submitted forms.
|
||||||
</Link>
|
</p>
|
||||||
{user?.canAdmin && (
|
<div className="mt-5 flex flex-wrap gap-3">
|
||||||
<Link
|
<Link to="/manage" className="btn bg-white text-brand-800 hover:bg-brand-50">
|
||||||
to="/manage/new"
|
Manage forms
|
||||||
className="btn border border-white/40 text-white hover:bg-white/10"
|
|
||||||
>
|
|
||||||
Create a form
|
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
{user?.canAdmin && (
|
||||||
|
<Link
|
||||||
|
to="/manage/new"
|
||||||
|
className="btn border border-white/40 text-white hover:bg-white/10"
|
||||||
|
>
|
||||||
|
Create a form
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -67,65 +71,65 @@ export function HomePage() {
|
||||||
{submissionLocked ? (
|
{submissionLocked ? (
|
||||||
<SignInPrompt message="Sign in with your organisation account to view and complete forms." />
|
<SignInPrompt message="Sign in with your organisation account to view and complete forms." />
|
||||||
) : (
|
) : (
|
||||||
<section>
|
<section>
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h2 className="text-xl font-bold">Available forms</h2>
|
<h2 className="text-xl font-bold">Available forms</h2>
|
||||||
<span className="text-sm text-slate-500 dark:text-slate-400">
|
<span className="text-sm text-slate-500 dark:text-slate-400">
|
||||||
{forms.length} active {forms.length === 1 ? 'form' : 'forms'}
|
{forms.length} active {forms.length === 1 ? 'form' : 'forms'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading && <p className="text-slate-500 dark:text-slate-400">Loading forms…</p>}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="card border-red-300 text-red-700 dark:border-red-900 dark:text-red-300">
|
|
||||||
{error}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && !error && forms.length === 0 && (
|
{loading && <p className="text-slate-500 dark:text-slate-400">Loading forms…</p>}
|
||||||
<div className="card text-center">
|
|
||||||
<p className="text-lg font-semibold">No active forms yet</p>
|
|
||||||
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
|
||||||
Create one from the management section to get started.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
{error && (
|
||||||
{forms.map((form) => (
|
<div className="card border-red-300 text-red-700 dark:border-red-900 dark:text-red-300">
|
||||||
<article key={form.id} className="card flex flex-col justify-between">
|
{error}
|
||||||
<div>
|
</div>
|
||||||
<div className="flex items-start justify-between gap-3">
|
)}
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{form.logoUrl && (
|
{!loading && !error && forms.length === 0 && (
|
||||||
<img
|
<div className="card text-center">
|
||||||
src={form.logoUrl}
|
<p className="text-lg font-semibold">No active forms yet</p>
|
||||||
alt=""
|
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||||
className="h-8 w-auto"
|
Create one from the management section to get started.
|
||||||
referrerPolicy="no-referrer"
|
</p>
|
||||||
/>
|
</div>
|
||||||
)}
|
)}
|
||||||
<h3 className="text-lg font-semibold">{form.title}</h3>
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{forms.map((form) => (
|
||||||
|
<article key={form.id} className="card flex flex-col justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{form.logoUrl && (
|
||||||
|
<img
|
||||||
|
src={form.logoUrl}
|
||||||
|
alt=""
|
||||||
|
className="h-8 w-auto"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<h3 className="text-lg font-semibold">{form.title}</h3>
|
||||||
|
</div>
|
||||||
|
<StatusBadge active={form.isActive} />
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge active={form.isActive} />
|
{form.description && (
|
||||||
</div>
|
<p className="mt-2 line-clamp-3 text-sm text-slate-500 dark:text-slate-400">
|
||||||
{form.description && (
|
{form.description}
|
||||||
<p className="mt-2 line-clamp-3 text-sm text-slate-500 dark:text-slate-400">
|
</p>
|
||||||
{form.description}
|
)}
|
||||||
|
<p className="mt-3 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Version {form.version} · {form.questionCount} questions
|
||||||
</p>
|
</p>
|
||||||
)}
|
</div>
|
||||||
<p className="mt-3 text-xs text-slate-500 dark:text-slate-400">
|
<Link to={`/forms/${form.id}`} className="btn-primary mt-5 w-full">
|
||||||
Version {form.version} · {form.questionCount} questions
|
Open form
|
||||||
</p>
|
</Link>
|
||||||
</div>
|
</article>
|
||||||
<Link to={`/forms/${form.id}`} className="btn-primary mt-5 w-full">
|
))}
|
||||||
Open form
|
</div>
|
||||||
</Link>
|
</section>
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ describe('StatusBadge', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('QuestionField', () => {
|
describe('QuestionField', () => {
|
||||||
it('renders boolean radios', () => {
|
it('renders boolean checkboxes', () => {
|
||||||
const onChange = vi.fn()
|
const onChange = vi.fn()
|
||||||
const { getByLabelText } = render(
|
const { getByLabelText } = render(
|
||||||
<QuestionField
|
<QuestionField
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue