{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "provider-gitlab-rhf",
  "title": "GitLab CI Provider (React Hook Form)",
  "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 React Hook Form.",
  "dependencies": [
    "react-hook-form"
  ],
  "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/rhf-none/provider-gitlab.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Controller, useForm, useWatch } from \"react-hook-form\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\nimport { Field, FieldDescription, FieldLabel } 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\ntype Scope = \"any\" | \"branch\" | \"tag\" | \"environment\"\n\ntype Fields = {\n  issuer: string\n  namespace: string\n  project: string\n  audience: string\n  scope: Scope\n  scopeValue: string\n  extraClaims: ClaimRow[]\n}\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<Fields>({\n    mode: \"onChange\",\n    defaultValues: defaultValue ? (parse(defaultValue) ?? DEFAULTS) : DEFAULTS,\n  })\n  const watched = {\n    scope: useWatch({ control: form.control, name: \"scope\" }),\n  }\n\n  const onChangeRef = React.useRef(onChange)\n  React.useEffect(() => {\n    onChangeRef.current = onChange\n  }, [onChange])\n\n  React.useEffect(() => {\n    return form.subscribe({\n      formState: { values: true },\n      callback: ({ values }) => {\n        const policy = compile(values)\n        onChangeRef.current?.(\n          defaultValue?.label\n            ? { ...policy, label: defaultValue.label }\n            : policy\n        )\n      },\n    })\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={form.handleSubmit((fields) => {\n        if (!claimsListValid(fields.extraClaims, FIRST_CLASS_CLAIMS)) {\n          return\n        }\n        const policy = compile(fields)\n        onSubmit(\n          defaultValue?.label\n            ? { ...policy, label: defaultValue.label }\n            : policy\n        )\n      })}\n    >\n      <Controller\n        control={form.control}\n        name=\"issuer\"\n        rules={{ required: true }}\n        render={({ field }) => (\n          <Field>\n            <FieldLabel htmlFor=\"gl-issuer\" className=\"w-full\">\n              Instance <ClaimChip>iss</ClaimChip>\n            </FieldLabel>\n            <Input\n              {...field}\n              id=\"gl-issuer\"\n              placeholder=\"https://gitlab.com\"\n              onChange={(event) => field.onChange(event.target.value.trim())}\n            />\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      <Controller\n        control={form.control}\n        name=\"namespace\"\n        rules={{ required: true }}\n        render={({ field }) => (\n          <Field>\n            <FieldLabel htmlFor=\"gl-namespace\" className=\"w-full\">\n              Group or username <ClaimChip>project_path</ClaimChip>\n            </FieldLabel>\n            <Input\n              {...field}\n              id=\"gl-namespace\"\n              placeholder=\"acme\"\n              onChange={(event) => field.onChange(event.target.value.trim())}\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.getValues(), ...patch },\n                  { keepDefaultValues: true }\n                )\n              }}\n            />\n            <FieldDescription>\n              Subgroups work too: acme/platform. Pasting a GitLab link fills the\n              form.\n            </FieldDescription>\n          </Field>\n        )}\n      />\n      <Controller\n        control={form.control}\n        name=\"project\"\n        rules={{ required: true }}\n        render={({ field }) => (\n          <Field>\n            <FieldLabel htmlFor=\"gl-project\" className=\"w-full\">\n              Project <ClaimChip>project_path</ClaimChip>\n            </FieldLabel>\n            <Input\n              {...field}\n              id=\"gl-project\"\n              placeholder=\"web\"\n              onChange={(event) => field.onChange(event.target.value.trim())}\n            />\n          </Field>\n        )}\n      />\n      <Controller\n        control={form.control}\n        name=\"audience\"\n        rules={{ required: true }}\n        render={({ field }) => (\n          <Field>\n            <FieldLabel htmlFor=\"gl-audience\" className=\"w-full\">\n              Audience <ClaimChip>aud</ClaimChip>\n            </FieldLabel>\n            <Input\n              {...field}\n              id=\"gl-audience\"\n              placeholder=\"https://your-app.example.com\"\n              onChange={(event) => field.onChange(event.target.value.trim())}\n            />\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      <Controller\n        control={form.control}\n        name=\"scope\"\n        render={({ field }) => (\n          <Field>\n            <FieldLabel htmlFor=\"gl-scope\">Scope by</FieldLabel>\n            <Select\n              items={SCOPE_OPTIONS}\n              value={field.value}\n              onValueChange={(next) => {\n                field.onChange((next ?? DEFAULTS.scope) as Scope)\n                form.setValue(\"scopeValue\", \"\")\n              }}\n            >\n              <SelectTrigger id=\"gl-scope\" className=\"w-full\">\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          </Field>\n        )}\n      />\n      {watched.scope !== \"any\" ? (\n        <Controller\n          control={form.control}\n          name=\"scopeValue\"\n          render={({ field }) => (\n            <Field>\n              <FieldLabel htmlFor=\"gl-scopeValue\" className=\"w-full\">\n                {watched.scope === \"branch\"\n                  ? \"Branch name\"\n                  : watched.scope === \"tag\"\n                    ? \"Tag name\"\n                    : \"Environment name\"}{\" \"}\n                <ClaimChip>\n                  {watched.scope === \"environment\" ? \"environment\" : \"ref\"}\n                </ClaimChip>\n              </FieldLabel>\n              <Input\n                {...field}\n                id=\"gl-scopeValue\"\n                placeholder={\n                  watched.scope === \"environment\" ? \"production\" : \"main\"\n                }\n              />\n            </Field>\n          )}\n        />\n      ) : null}\n      <Controller\n        control={form.control}\n        name=\"extraClaims\"\n        render={({ field: controller }) => (\n          <ClaimsList\n            value={controller.value}\n            onChange={controller.onChange}\n            reserved={FIRST_CLASS_CLAIMS}\n          />\n        )}\n      />\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"
}