{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "provider-gitlab-tanstack-valibot",
  "title": "GitLab CI Provider (TanStack Form + Valibot)",
  "description": "A self-contained form component that emits an OidcPolicy for a GitLab CI caller: instance (gitlab.com or self-hosted), group and project, job-requested audience, and branch/tag/environment scoping. Built with TanStack Form and validated with Valibot.",
  "dependencies": [
    "@tanstack/react-form",
    "valibot"
  ],
  "registryDependencies": [
    "https://ui.shadcn.com/oidc/r/policy.json",
    "https://ui.shadcn.com/oidc/r/claims.json",
    "button",
    "field",
    "input",
    "select"
  ],
  "files": [
    {
      "path": "registry/oidc/flavors/tanstack-valibot/provider-gitlab.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { useForm, useStore } from \"@tanstack/react-form\"\nimport * as v from \"valibot\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\nimport {\n  Field,\n  FieldDescription,\n  FieldError,\n  FieldLabel,\n} from \"@/components/ui/field\"\nimport { Input } from \"@/components/ui/input\"\nimport {\n  Select,\n  SelectContent,\n  SelectGroup,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\"\nimport {\n  ClaimChip,\n  ClaimsList,\n  claimRowsFromPolicy,\n  claimsListValid,\n  mergeClaimRows,\n  type ClaimRow,\n} from \"@/components/oidc/claims\"\nimport type { OidcPolicy } from \"@/lib/oidc/policy\"\n\nfunction GitlabIcon({ ...props }: React.ComponentProps<\"svg\">) {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"currentColor\" {...props}>\n      <path d=\"m23.6004 9.5927-.0337-.0862L20.3.9814a.851.851 0 0 0-.3362-.405.8748.8748 0 0 0-.9997.0539.8748.8748 0 0 0-.29.4399l-2.2055 6.748H7.5375l-2.2057-6.748a.8573.8573 0 0 0-.29-.4412.8748.8748 0 0 0-.9997-.0537.8585.8585 0 0 0-.3362.4049L.4332 9.5015l-.0325.0862a6.0657 6.0657 0 0 0 2.0119 7.0105l.0113.0087.03.0213 4.976 3.7264 2.462 1.8633 1.4995 1.1321a1.0085 1.0085 0 0 0 1.2197 0l1.4995-1.1321 2.4619-1.8633 5.006-3.7489.0125-.01a6.0682 6.0682 0 0 0 2.0094-7.003z\" />\n    </svg>\n  )\n}\n\nconst issuerSchema = v.pipe(\n  v.string(),\n  v.minLength(1, \"Enter the instance URL.\"),\n  v.regex(\n    /^https:\\/\\/[^?#\\s]+$/,\n    \"Enter an https:// URL without query or fragment.\"\n  )\n)\nconst namespaceSchema = v.pipe(\n  v.string(),\n  v.minLength(1, \"Enter the group or username.\")\n)\nconst projectSchema = v.pipe(v.string(), v.minLength(1, \"Enter the project.\"))\nconst audienceSchema = v.pipe(v.string(), v.minLength(1, \"Enter the audience.\"))\nconst scopeSchema = v.picklist([\"any\", \"branch\", \"tag\", \"environment\"])\nconst scopeValueSchema = v.string()\n\ntype Fields = {\n  issuer: v.InferOutput<typeof issuerSchema>\n  namespace: v.InferOutput<typeof namespaceSchema>\n  project: v.InferOutput<typeof projectSchema>\n  audience: v.InferOutput<typeof audienceSchema>\n  scope: v.InferOutput<typeof scopeSchema>\n  scopeValue: v.InferOutput<typeof scopeValueSchema>\n  extraClaims: ClaimRow[]\n}\ntype Scope = Fields[\"scope\"]\n\nconst SCOPE_OPTIONS: { label: string; value: Scope }[] = [\n  { label: \"Any branch or tag\", value: \"any\" },\n  { label: \"Branch\", value: \"branch\" },\n  { label: \"Tag\", value: \"tag\" },\n  { label: \"Environment\", value: \"environment\" },\n]\n\nconst DEFAULTS: Fields = {\n  issuer: \"https://gitlab.com\",\n  namespace: \"\",\n  project: \"\",\n  audience: \"\",\n  scope: \"any\",\n  scopeValue: \"\",\n  extraClaims: [],\n}\n\nconst FIRST_CLASS_CLAIMS = [\n  \"aud\",\n  \"project_path\",\n  \"ref\",\n  \"ref_type\",\n  \"environment\",\n]\n\n// Turns a pasted GitLab link into a field patch. Self-hosted hosts count\n// only when the /-/ marker makes the URL unmistakably GitLab.\nfunction decodeSegment(segment: string) {\n  try {\n    return decodeURIComponent(segment)\n  } catch {\n    return null\n  }\n}\n\nfunction parsePastedProject(text: string) {\n  const input = text.trim()\n  const ssh = input.match(/^git@([\\w.-]+):(.+?)(?:\\.git)?$/)\n  const web = input.match(\n    /^(?:https?:\\/\\/)?([\\w.-]+(?::\\d+)?)\\/([^?#]+?)(?:[?#].*)?$/\n  )\n  const host = ssh?.[1] ?? web?.[1]\n  const fullPath = ssh?.[2] ?? web?.[2]\n  if (!host || !fullPath) {\n    return null\n  }\n  if ([\"github.com\", \"www.github.com\", \"bitbucket.org\"].includes(host)) {\n    return null\n  }\n  if (host !== \"gitlab.com\" && !fullPath.includes(\"/-/\")) {\n    return null\n  }\n  const cleaned = fullPath.replace(/\\.git$/, \"\")\n  const markerIndex = cleaned.indexOf(\"/-/\")\n  const projectPath =\n    markerIndex === -1 ? cleaned : cleaned.slice(0, markerIndex)\n  const marker = markerIndex === -1 ? \"\" : cleaned.slice(markerIndex + 3)\n  const segments = projectPath.split(\"/\").filter(Boolean)\n  if (segments.length < 2) {\n    return null\n  }\n  const patch = {\n    issuer: `https://${host}`,\n    namespace: segments.slice(0, -1).join(\"/\"),\n    project: segments[segments.length - 1],\n  }\n  const tag = marker.match(/^tags\\/(.+?)\\/?$/)\n  if (tag) {\n    const scopeValue = decodeSegment(tag[1])\n    if (scopeValue !== null) {\n      return { ...patch, scope: \"tag\" as const, scopeValue }\n    }\n  }\n  const branch = marker.match(/^tree\\/(.+?)\\/?$/)\n  if (branch) {\n    const scopeValue = decodeSegment(branch[1])\n    if (scopeValue !== null) {\n      return { ...patch, scope: \"branch\" as const, scopeValue }\n    }\n  }\n  return patch\n}\n\n// GitLab refs carry no refs/heads prefix; branch vs tag is the ref_type\n// claim. The audience is whatever the job requests in its id_tokens config —\n// there is no platform default.\nfunction compile(fields: Fields) {\n  const claims: Record<string, string[]> = {}\n  if (fields.audience) {\n    claims.aud = [fields.audience]\n  }\n  if (fields.namespace && fields.project) {\n    claims.project_path = [`${fields.namespace}/${fields.project}`]\n  }\n  if (fields.scopeValue) {\n    if (fields.scope === \"branch\") {\n      claims.ref = [fields.scopeValue]\n      claims.ref_type = [\"branch\"]\n    } else if (fields.scope === \"tag\") {\n      claims.ref = [fields.scopeValue]\n      claims.ref_type = [\"tag\"]\n    } else if (fields.scope === \"environment\") {\n      claims.environment = [fields.scopeValue]\n    }\n  }\n  mergeClaimRows(claims, fields.extraClaims, FIRST_CLASS_CLAIMS)\n  return { issuer: fields.issuer, claims }\n}\n\nfunction parse(policy: OidcPolicy) {\n  if (!policy.issuer.startsWith(\"https://\")) {\n    return null\n  }\n  if (\n    FIRST_CLASS_CLAIMS.some((name) => (policy.claims[name]?.length ?? 0) > 1)\n  ) {\n    return null\n  }\n  const extraClaims = claimRowsFromPolicy(policy.claims, FIRST_CLASS_CLAIMS)\n  if (extraClaims === null) {\n    return null\n  }\n\n  const [projectPath = \"\"] = policy.claims.project_path ?? []\n  const segments = projectPath.split(\"/\")\n  const project = segments.pop() ?? \"\"\n  const namespace = segments.join(\"/\")\n  if (!namespace || !project) {\n    return null\n  }\n\n  const [ref] = policy.claims.ref ?? []\n  const [refType] = policy.claims.ref_type ?? []\n  const [environment] = policy.claims.environment ?? []\n  if (ref && environment) {\n    return null\n  }\n  if (ref && refType !== \"branch\" && refType !== \"tag\") {\n    return null\n  }\n  if (!ref && refType) {\n    return null\n  }\n\n  let scope: Scope = \"any\"\n  let scopeValue = \"\"\n  if (ref && refType === \"branch\") {\n    scope = \"branch\"\n    scopeValue = ref\n  } else if (ref && refType === \"tag\") {\n    scope = \"tag\"\n    scopeValue = ref\n  } else if (environment) {\n    scope = \"environment\"\n    scopeValue = environment\n  }\n\n  return {\n    issuer: policy.issuer,\n    namespace,\n    project,\n    audience: policy.claims.aud?.[0] ?? \"\",\n    scope,\n    scopeValue,\n    extraClaims,\n  }\n}\n\n/** True when this form can represent the policy — for routing an edit. */\nfunction matchesGitlabPolicy(policy: OidcPolicy) {\n  return parse(policy) !== null\n}\n\nfunction GitlabPolicyForm({\n  defaultValue,\n  onChange,\n  onSubmit,\n  className,\n}: {\n  defaultValue?: OidcPolicy\n  onChange?: (policy: OidcPolicy) => void\n  onSubmit: (policy: OidcPolicy) => void\n  className?: string\n}) {\n  const form = useForm({\n    defaultValues: (defaultValue\n      ? (parse(defaultValue) ?? DEFAULTS)\n      : DEFAULTS) as Fields,\n    onSubmit: ({ value: fields }) => {\n      if (!claimsListValid(fields.extraClaims)) {\n        return\n      }\n      const policy = compile(fields)\n      onSubmit(\n        defaultValue?.label ? { ...policy, label: defaultValue.label } : policy\n      )\n    },\n  })\n  const values = {\n    scope: useStore(form.store, (state) => state.values.scope),\n  }\n\n  const onChangeRef = React.useRef(onChange)\n  React.useEffect(() => {\n    onChangeRef.current = onChange\n  }, [onChange])\n\n  React.useEffect(() => {\n    let previousValues = form.store.state.values\n    const subscription = form.store.subscribe(() => {\n      const { values } = form.store.state\n      if (values === previousValues) {\n        return\n      }\n      previousValues = values\n      const policy = compile(values)\n      onChangeRef.current?.(\n        defaultValue?.label ? { ...policy, label: defaultValue.label } : policy\n      )\n    })\n    return () => subscription.unsubscribe()\n  }, [form, defaultValue?.label])\n\n  return (\n    <form\n      data-slot=\"gitlab-policy-form\"\n      className={cn(\"flex flex-col gap-4\", className)}\n      onSubmit={(event) => {\n        event.preventDefault()\n        event.stopPropagation()\n        void form.handleSubmit()\n      }}\n    >\n      <form.Field name=\"issuer\" validators={{ onChange: issuerSchema }}>\n        {(field) => {\n          const isInvalid =\n            field.state.meta.isTouched && !field.state.meta.isValid\n          return (\n            <Field data-invalid={isInvalid}>\n              <FieldLabel htmlFor=\"gl-issuer\" className=\"w-full\">\n                Instance <ClaimChip>iss</ClaimChip>\n              </FieldLabel>\n              <Input\n                id=\"gl-issuer\"\n                value={field.state.value}\n                placeholder=\"https://gitlab.com\"\n                aria-invalid={isInvalid}\n                onBlur={field.handleBlur}\n                onChange={(event) =>\n                  field.handleChange(event.target.value.trim())\n                }\n              />\n              {isInvalid && <FieldError errors={field.state.meta.errors} />}\n              <FieldDescription>\n                Keep https://gitlab.com for the hosted service, or enter your\n                self-hosted instance URL.\n              </FieldDescription>\n            </Field>\n          )\n        }}\n      </form.Field>\n      <form.Field name=\"namespace\" validators={{ onChange: namespaceSchema }}>\n        {(field) => {\n          const isInvalid =\n            field.state.meta.isTouched && !field.state.meta.isValid\n          return (\n            <Field data-invalid={isInvalid}>\n              <FieldLabel htmlFor=\"gl-namespace\" className=\"w-full\">\n                Group or username <ClaimChip>project_path</ClaimChip>\n              </FieldLabel>\n              <Input\n                id=\"gl-namespace\"\n                value={field.state.value}\n                placeholder=\"acme\"\n                aria-invalid={isInvalid}\n                onBlur={field.handleBlur}\n                onChange={(event) =>\n                  field.handleChange(event.target.value.trim())\n                }\n                onPaste={(event) => {\n                  const patch = parsePastedProject(\n                    event.clipboardData.getData(\"text\")\n                  )\n                  if (!patch) {\n                    return\n                  }\n                  event.preventDefault()\n                  form.reset(\n                    { ...form.store.state.values, ...patch },\n                    { keepDefaultValues: true }\n                  )\n                }}\n              />\n              {isInvalid && <FieldError errors={field.state.meta.errors} />}\n              <FieldDescription>\n                Subgroups work too: acme/platform. Pasting a GitLab link fills\n                the form.\n              </FieldDescription>\n            </Field>\n          )\n        }}\n      </form.Field>\n      <form.Field name=\"project\" validators={{ onChange: projectSchema }}>\n        {(field) => {\n          const isInvalid =\n            field.state.meta.isTouched && !field.state.meta.isValid\n          return (\n            <Field data-invalid={isInvalid}>\n              <FieldLabel htmlFor=\"gl-project\" className=\"w-full\">\n                Project <ClaimChip>project_path</ClaimChip>\n              </FieldLabel>\n              <Input\n                id=\"gl-project\"\n                value={field.state.value}\n                placeholder=\"web\"\n                aria-invalid={isInvalid}\n                onBlur={field.handleBlur}\n                onChange={(event) =>\n                  field.handleChange(event.target.value.trim())\n                }\n              />\n              {isInvalid && <FieldError errors={field.state.meta.errors} />}\n            </Field>\n          )\n        }}\n      </form.Field>\n      <form.Field name=\"audience\" validators={{ onChange: audienceSchema }}>\n        {(field) => {\n          const isInvalid =\n            field.state.meta.isTouched && !field.state.meta.isValid\n          return (\n            <Field data-invalid={isInvalid}>\n              <FieldLabel htmlFor=\"gl-audience\" className=\"w-full\">\n                Audience <ClaimChip>aud</ClaimChip>\n              </FieldLabel>\n              <Input\n                id=\"gl-audience\"\n                value={field.state.value}\n                placeholder=\"https://your-app.example.com\"\n                aria-invalid={isInvalid}\n                onBlur={field.handleBlur}\n                onChange={(event) =>\n                  field.handleChange(event.target.value.trim())\n                }\n              />\n              {isInvalid && <FieldError errors={field.state.meta.errors} />}\n              <FieldDescription>\n                The aud your job requests in its id_tokens config. Pick a value\n                unique to your app.\n              </FieldDescription>\n            </Field>\n          )\n        }}\n      </form.Field>\n      <form.Field name=\"scope\" validators={{ onChange: scopeSchema }}>\n        {(field) => {\n          const isInvalid =\n            field.state.meta.isTouched && !field.state.meta.isValid\n          return (\n            <Field data-invalid={isInvalid}>\n              <FieldLabel htmlFor=\"gl-scope\">Scope by</FieldLabel>\n              <Select\n                items={SCOPE_OPTIONS}\n                value={field.state.value}\n                onValueChange={(next) => {\n                  field.handleChange((next ?? DEFAULTS.scope) as Scope)\n                  form.setFieldValue(\"scopeValue\", \"\")\n                }}\n              >\n                <SelectTrigger\n                  id=\"gl-scope\"\n                  className=\"w-full\"\n                  aria-invalid={isInvalid}\n                >\n                  <SelectValue />\n                </SelectTrigger>\n                <SelectContent>\n                  <SelectGroup>\n                    {SCOPE_OPTIONS.map((option) => (\n                      <SelectItem key={option.value} value={option.value}>\n                        {option.label}\n                      </SelectItem>\n                    ))}\n                  </SelectGroup>\n                </SelectContent>\n              </Select>\n              {isInvalid && <FieldError errors={field.state.meta.errors} />}\n            </Field>\n          )\n        }}\n      </form.Field>\n      {values.scope !== \"any\" ? (\n        <form.Field\n          name=\"scopeValue\"\n          validators={{ onChange: scopeValueSchema }}\n        >\n          {(field) => {\n            const isInvalid =\n              field.state.meta.isTouched && !field.state.meta.isValid\n            return (\n              <Field data-invalid={isInvalid}>\n                <FieldLabel htmlFor=\"gl-scopeValue\" className=\"w-full\">\n                  {values.scope === \"branch\"\n                    ? \"Branch name\"\n                    : values.scope === \"tag\"\n                      ? \"Tag name\"\n                      : \"Environment name\"}{\" \"}\n                  <ClaimChip>\n                    {values.scope === \"environment\" ? \"environment\" : \"ref\"}\n                  </ClaimChip>\n                </FieldLabel>\n                <Input\n                  id=\"gl-scopeValue\"\n                  value={field.state.value}\n                  placeholder={\n                    values.scope === \"environment\" ? \"production\" : \"main\"\n                  }\n                  aria-invalid={isInvalid}\n                  onBlur={field.handleBlur}\n                  onChange={(event) => field.handleChange(event.target.value)}\n                />\n                {isInvalid && <FieldError errors={field.state.meta.errors} />}\n              </Field>\n            )\n          }}\n        </form.Field>\n      ) : null}\n      <form.Field name=\"extraClaims\">\n        {(field) => (\n          <ClaimsList\n            value={field.state.value}\n            onChange={field.handleChange}\n            reserved={FIRST_CLASS_CLAIMS}\n          />\n        )}\n      </form.Field>\n      <Button type=\"submit\" className=\"self-start\">\n        Save\n      </Button>\n    </form>\n  )\n}\n\nexport { GitlabIcon, GitlabPolicyForm, matchesGitlabPolicy }\n",
      "type": "registry:component",
      "target": "@components/oidc/provider-gitlab.tsx"
    }
  ],
  "meta": {
    "tagline": "Trust jobs from a project's pipelines"
  },
  "categories": [
    "auth",
    "forms"
  ],
  "type": "registry:component"
}