REST API: scoped PATs + create/import repos under an org
bc58dcd · gmorell · 2026-06-25 22:17
Message
{commit_body(@commit)}
Files changed
modified
lib/git_gud/accounts.ex
+58
−0
@@ -7,6 +7,64 @@ defmodule GitGud.Accounts do
| 7 | 7 | alias GitGud.Repo |
| 8 | 8 | |
| 9 | 9 | alias GitGud.Accounts.{User, UserToken, UserNotifier} |
| 10 | + alias GitGud.Accounts.PersonalAccessToken | |
| 11 | + | |
| 12 | + @pat_prefix "ggp_" | |
| 13 | + | |
| 14 | + # ── personal access tokens (REST API) ──────────────────────────────── | |
| 15 | + | |
| 16 | + @doc "A user's API tokens, by name." | |
| 17 | + def list_personal_access_tokens(%User{id: uid}) do | |
| 18 | + from(t in PersonalAccessToken, where: t.user_id == ^uid, order_by: [asc: t.name]) | |
| 19 | + |> Repo.all() | |
| 20 | + end | |
| 21 | + | |
| 22 | + @doc """ | |
| 23 | + Mint a token for `user` with `scopes` (subset of | |
| 24 | + `PersonalAccessToken.scopes/0`). Returns `{:ok, token, plaintext}`; | |
| 25 | + the `ggp_…` plaintext is only available here — we store its hash. | |
| 26 | + """ | |
| 27 | + def create_personal_access_token(%User{id: uid}, name, scopes) do | |
| 28 | + plaintext = @pat_prefix <> (:crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false)) | |
| 29 | + | |
| 30 | + %PersonalAccessToken{user_id: uid} | |
| 31 | + |> PersonalAccessToken.changeset(%{name: name, token_hash: pat_hash(plaintext), scopes: scopes}) | |
| 32 | + |> Repo.insert() | |
| 33 | + |> case do | |
| 34 | + {:ok, token} -> {:ok, token, plaintext} | |
| 35 | + {:error, _} = err -> err | |
| 36 | + end | |
| 37 | + end | |
| 38 | + | |
| 39 | + def delete_personal_access_token(%User{id: uid}, id) do | |
| 40 | + Repo.delete_all(from t in PersonalAccessToken, where: t.user_id == ^uid and t.id == ^id) | |
| 41 | + :ok | |
| 42 | + end | |
| 43 | + | |
| 44 | + @doc """ | |
| 45 | + Verify a presented API token. On a match, bumps `last_used_at` and | |
| 46 | + returns `{:ok, %User{}, scopes}`; otherwise `:error`. | |
| 47 | + """ | |
| 48 | + def verify_personal_access_token(@pat_prefix <> _ = plaintext) do | |
| 49 | + case Repo.get_by(PersonalAccessToken, token_hash: pat_hash(plaintext)) do | |
| 50 | + %PersonalAccessToken{} = token -> | |
| 51 | + token = Repo.preload(token, :user) | |
| 52 | + | |
| 53 | + Repo.update_all( | |
| 54 | + from(t in PersonalAccessToken, where: t.id == ^token.id), | |
| 55 | + set: [last_used_at: DateTime.utc_now(:second)] | |
| 56 | + ) | |
| 57 | + | |
| 58 | + {:ok, token.user, token.scopes} | |
| 59 | + | |
| 60 | + nil -> | |
| 61 | + :error | |
| 62 | + end | |
| 63 | + end | |
| 64 | + | |
| 65 | + def verify_personal_access_token(_), do: :error | |
| 66 | + | |
| 67 | + defp pat_hash(plaintext), do: :crypto.hash(:sha256, plaintext) | |
| 10 | 68 | |
| 11 | 69 | ## Database getters |
| 12 | 70 |
added
lib/git_gud/accounts/personal_access_token.ex
+46
−0
@@ -0,0 +1,46 @@
| 1 | +defmodule GitGud.Accounts.PersonalAccessToken do | |
| 2 | + @moduledoc """ | |
| 3 | + A user-scoped API token (`ggp_…`) for the REST API. Only the SHA-256 | |
| 4 | + `token_hash` is stored; the plaintext is shown once at creation. | |
| 5 | + | |
| 6 | + `scopes` is a subset of the extensible vocabulary in `scopes/0`. The | |
| 7 | + API authorizes each request against the required scope, so new scopes | |
| 8 | + can be added here over time without changing existing tokens. Today: | |
| 9 | + | |
| 10 | + * `repo` — create / manage repositories | |
| 11 | + """ | |
| 12 | + use Ecto.Schema | |
| 13 | + | |
| 14 | + import Ecto.Changeset | |
| 15 | + | |
| 16 | + alias GitGud.Accounts.User | |
| 17 | + | |
| 18 | + # Extend this list to add scopes; tokens are validated against it. | |
| 19 | + @scopes ~w(repo) | |
| 20 | + | |
| 21 | + schema "personal_access_tokens" do | |
| 22 | + field :name, :string | |
| 23 | + field :token_hash, :binary, redact: true | |
| 24 | + field :scopes, {:array, :string}, default: [] | |
| 25 | + field :last_used_at, :utc_datetime | |
| 26 | + | |
| 27 | + belongs_to :user, User | |
| 28 | + | |
| 29 | + timestamps(type: :utc_datetime) | |
| 30 | + end | |
| 31 | + | |
| 32 | + @doc false | |
| 33 | + def changeset(token, attrs) do | |
| 34 | + token | |
| 35 | + |> cast(attrs, [:name, :token_hash, :scopes, :user_id]) | |
| 36 | + |> validate_required([:name, :token_hash, :user_id]) | |
| 37 | + |> validate_length(:name, min: 1, max: 100) | |
| 38 | + |> validate_subset(:scopes, @scopes) | |
| 39 | + |> validate_length(:scopes, min: 1) | |
| 40 | + |> unique_constraint(:name, name: :personal_access_tokens_user_id_name_index) | |
| 41 | + |> unique_constraint(:token_hash) | |
| 42 | + end | |
| 43 | + | |
| 44 | + @doc "The set of scopes a token may carry." | |
| 45 | + def scopes, do: @scopes | |
| 46 | +end |
modified
lib/git_gud/repositories.ex
+28
−2
@@ -503,7 +503,16 @@ defmodule GitGud.Repositories do
| 503 | 503 | which is stable pre-insert. |
| 504 | 504 | """ |
| 505 | 505 | def create_repository(%User{} = owner, attrs) do |
| 506 | − do_create_repository(owner, attrs, %{owner_id: owner.id, organization_id: nil}) | |
| 506 | + do_create_repository(owner, attrs, clone_fk(%{owner_id: owner.id, organization_id: nil}, attrs)) | |
| 507 | + end | |
| 508 | + | |
| 509 | + # Thread an optional remote import URL (`clone_addr`) into the fk so | |
| 510 | + # init_function/1 mirror-clones it instead of starting empty. | |
| 511 | + defp clone_fk(fk, attrs) do | |
| 512 | + case attrs["clone_addr"] || attrs[:clone_addr] do | |
| 513 | + url when is_binary(url) and url != "" -> Map.put(fk, :clone_from_url, url) | |
| 514 | + _ -> fk | |
| 515 | + end | |
| 507 | 516 | end |
| 508 | 517 | |
| 509 | 518 | @doc """ |
@@ -521,7 +530,9 @@ defmodule GitGud.Repositories do
| 521 | 530 | {:error, {:visibility_too_open, org.visibility}} |
| 522 | 531 | |
| 523 | 532 | true -> |
| 524 | − case do_create_repository(org, attrs, %{owner_id: creator.id, organization_id: org.id}) do | |
| 533 | + fk = clone_fk(%{owner_id: creator.id, organization_id: org.id}, attrs) | |
| 534 | + | |
| 535 | + case do_create_repository(org, attrs, fk) do | |
| 525 | 536 | {:ok, repo} = ok -> |
| 526 | 537 | _ = GitGud.Federation.Publishing.publish_org_repo_created(org, repo) |
| 527 | 538 | ok |
@@ -571,6 +582,10 @@ defmodule GitGud.Repositories do
| 571 | 582 | end) |
| 572 | 583 | end |
| 573 | 584 | |
| 585 | + defp init_function(%{clone_from_url: url}) when is_binary(url) do | |
| 586 | + fn dest -> clone_mirror(url, dest) end | |
| 587 | + end | |
| 588 | + | |
| 574 | 589 | defp init_function(%{clone_from: source_path}) when is_binary(source_path) do |
| 575 | 590 | fn dest -> clone_bare(source_path, dest) end |
| 576 | 591 | end |
@@ -586,6 +601,17 @@ defmodule GitGud.Repositories do
| 586 | 601 | end |
| 587 | 602 | end |
| 588 | 603 | |
| 604 | + # Import from a remote URL (e.g. github.com/neiam/foo.git). `--mirror` | |
| 605 | + # grabs all branches/tags/notes, which is what a full import wants. | |
| 606 | + defp clone_mirror(url, dest) do | |
| 607 | + File.mkdir_p!(Path.dirname(dest)) | |
| 608 | + | |
| 609 | + case System.cmd("git", ["clone", "--mirror", url, dest], stderr_to_stdout: true) do | |
| 610 | + {_out, 0} -> :ok | |
| 611 | + {out, _} -> {:error, {:git_clone, out}} | |
| 612 | + end | |
| 613 | + end | |
| 614 | + | |
| 589 | 615 | @doc """ |
| 590 | 616 | Fork `source_repo` under `dest_namespace` (a `%User{}` or |
| 591 | 617 | `%Organization{}`). The new repo's bare on-disk store is a clone of |
modified
lib/git_gud_web/components/layouts.ex
+5
−0
@@ -96,6 +96,11 @@ defmodule GitGudWeb.Layouts do
| 96 | 96 | <.icon name="hero-key-micro" class="size-4" /> SSH keys |
| 97 | 97 | </.link> |
| 98 | 98 | </li> |
| 99 | + <li> | |
| 100 | + <.link navigate={~p"/users/settings/tokens"}> | |
| 101 | + <.icon name="hero-command-line-micro" class="size-4" /> API tokens | |
| 102 | + </.link> | |
| 103 | + </li> | |
| 99 | 104 | <li> |
| 100 | 105 | <.link href={~p"/users/log-out"} method="delete"> |
| 101 | 106 | <.icon name="hero-arrow-right-on-rectangle-micro" class="size-4" /> Log out |
added
lib/git_gud_web/controllers/api/v1/org_controller.ex
+95
−0
@@ -0,0 +1,95 @@
| 1 | +defmodule GitGudWeb.Api.V1.OrgController do | |
| 2 | + @moduledoc """ | |
| 3 | + Org-scoped REST API, authenticated with a Personal Access Token | |
| 4 | + (`Authorization: token ggp_…` or `Bearer …`) carrying the `repo` scope. | |
| 5 | + | |
| 6 | + `POST /api/v1/orgs/:org/repos` | |
| 7 | + { "name": "foo", | |
| 8 | + "visibility": "private", # optional, default private | |
| 9 | + "description": "...", # optional | |
| 10 | + "clone_addr": "https://github.com/neiam/foo.git" } # optional → import | |
| 11 | + | |
| 12 | + With no `clone_addr` the repo is created empty (push your local repo to | |
| 13 | + it); with one, the forge mirror-clones the remote. | |
| 14 | + """ | |
| 15 | + use GitGudWeb, :controller | |
| 16 | + | |
| 17 | + import Plug.Conn | |
| 18 | + | |
| 19 | + alias GitGud.Accounts | |
| 20 | + alias GitGud.Organizations | |
| 21 | + alias GitGud.Repositories | |
| 22 | + | |
| 23 | + def create_repo(conn, %{"org" => handle} = params) do | |
| 24 | + with {:ok, user} <- authenticate(conn, "repo"), | |
| 25 | + %_{} = org <- Organizations.get_organization_by_handle(handle) do | |
| 26 | + attrs = %{ | |
| 27 | + "name" => params["name"], | |
| 28 | + "description" => params["description"], | |
| 29 | + "visibility" => params["visibility"] || "private", | |
| 30 | + "clone_addr" => params["clone_addr"] | |
| 31 | + } | |
| 32 | + | |
| 33 | + case Repositories.create_repository_for_org(org, user, attrs) do | |
| 34 | + {:ok, repo} -> | |
| 35 | + conn |> put_status(201) |> json(repo_json(handle, repo)) | |
| 36 | + | |
| 37 | + {:error, :forbidden} -> | |
| 38 | + send_error(conn, 403, "must be an admin of #{handle}") | |
| 39 | + | |
| 40 | + {:error, {:visibility_too_open, org_vis}} -> | |
| 41 | + send_error(conn, 422, "visibility must be no more open than the org (#{org_vis})") | |
| 42 | + | |
| 43 | + {:error, %Ecto.Changeset{} = cs} -> | |
| 44 | + send_error(conn, 422, changeset_message(cs)) | |
| 45 | + | |
| 46 | + {:error, reason} -> | |
| 47 | + send_error(conn, 422, inspect(reason)) | |
| 48 | + end | |
| 49 | + else | |
| 50 | + :error -> send_error(conn, 401, "invalid token or missing 'repo' scope") | |
| 51 | + nil -> send_error(conn, 404, "organization not found") | |
| 52 | + end | |
| 53 | + end | |
| 54 | + | |
| 55 | + defp authenticate(conn, scope) do | |
| 56 | + with token when is_binary(token) <- bearer(conn), | |
| 57 | + {:ok, user, scopes} <- Accounts.verify_personal_access_token(token), | |
| 58 | + true <- scope in scopes do | |
| 59 | + {:ok, user} | |
| 60 | + else | |
| 61 | + _ -> :error | |
| 62 | + end | |
| 63 | + end | |
| 64 | + | |
| 65 | + defp bearer(conn) do | |
| 66 | + case get_req_header(conn, "authorization") do | |
| 67 | + ["token " <> t | _] -> t | |
| 68 | + ["Bearer " <> t | _] -> t | |
| 69 | + _ -> nil | |
| 70 | + end | |
| 71 | + end | |
| 72 | + | |
| 73 | + defp repo_json(handle, repo) do | |
| 74 | + full = handle <> "/" <> repo.name | |
| 75 | + base = GitGudWeb.Endpoint.url() | |
| 76 | + | |
| 77 | + %{ | |
| 78 | + "id" => repo.id, | |
| 79 | + "name" => repo.name, | |
| 80 | + "full_name" => full, | |
| 81 | + "private" => repo.visibility == "private", | |
| 82 | + "default_branch" => repo.default_branch, | |
| 83 | + "html_url" => base <> "/r/" <> full, | |
| 84 | + "clone_url" => base <> "/" <> full <> ".git" | |
| 85 | + } | |
| 86 | + end | |
| 87 | + | |
| 88 | + defp changeset_message(cs) do | |
| 89 | + cs | |
| 90 | + |> Ecto.Changeset.traverse_errors(fn {m, _} -> m end) | |
| 91 | + |> Enum.map_join("; ", fn {k, v} -> "#{k} #{Enum.join(v, ", ")}" end) | |
| 92 | + end | |
| 93 | + | |
| 94 | + defp send_error(conn, status, msg), do: conn |> put_status(status) |> json(%{"message" => msg}) | |
| 95 | +end |
added
lib/git_gud_web/live/personal_access_token_live/index.ex
+147
−0
@@ -0,0 +1,147 @@
| 1 | +defmodule GitGudWeb.PersonalAccessTokenLive.Index do | |
| 2 | + @moduledoc """ | |
| 3 | + User settings page for Personal Access Tokens — credentials for the | |
| 4 | + REST API (`/api/v1`). The plaintext is shown once at creation; only the | |
| 5 | + hash is stored. Scopes are selectable and validated against an | |
| 6 | + extensible set, so more can be added later. | |
| 7 | + """ | |
| 8 | + use GitGudWeb, :live_view | |
| 9 | + | |
| 10 | + alias GitGud.Accounts | |
| 11 | + alias GitGud.Accounts.PersonalAccessToken | |
| 12 | + | |
| 13 | + @impl true | |
| 14 | + def mount(_params, _session, socket) do | |
| 15 | + user = socket.assigns.current_scope.user | |
| 16 | + | |
| 17 | + {:ok, | |
| 18 | + socket | |
| 19 | + |> assign(:user, user) | |
| 20 | + |> assign(:all_scopes, PersonalAccessToken.scopes()) | |
| 21 | + |> assign(:new_token, nil) | |
| 22 | + |> assign(:api_base, GitGudWeb.Endpoint.url()) | |
| 23 | + |> load() | |
| 24 | + |> assign(:page_title, "API tokens")} | |
| 25 | + end | |
| 26 | + | |
| 27 | + defp load(socket) do | |
| 28 | + assign(socket, :tokens, Accounts.list_personal_access_tokens(socket.assigns.user)) | |
| 29 | + end | |
| 30 | + | |
| 31 | + @impl true | |
| 32 | + def handle_event("create_token", params, socket) do | |
| 33 | + cond do | |
| 34 | + String.trim(params["name"] || "") == "" -> | |
| 35 | + {:noreply, put_flash(socket, :error, "Name required.")} | |
| 36 | + | |
| 37 | + (params["scopes"] || []) == [] -> | |
| 38 | + {:noreply, put_flash(socket, :error, "Pick at least one scope.")} | |
| 39 | + | |
| 40 | + true -> | |
| 41 | + name = String.trim(params["name"]) | |
| 42 | + | |
| 43 | + case Accounts.create_personal_access_token(socket.assigns.user, name, params["scopes"]) do | |
| 44 | + {:ok, _token, plaintext} -> | |
| 45 | + {:noreply, | |
| 46 | + socket | |
| 47 | + |> load() | |
| 48 | + |> assign(:new_token, %{name: name, secret: plaintext}) | |
| 49 | + |> put_flash(:info, "Token created — copy it now, it won't be shown again.")} | |
| 50 | + | |
| 51 | + {:error, cs} -> | |
| 52 | + {:noreply, put_flash(socket, :error, error_message(cs))} | |
| 53 | + end | |
| 54 | + end | |
| 55 | + end | |
| 56 | + | |
| 57 | + def handle_event("delete_token", %{"id" => id}, socket) do | |
| 58 | + :ok = Accounts.delete_personal_access_token(socket.assigns.user, String.to_integer(id)) | |
| 59 | + {:noreply, socket |> load() |> assign(:new_token, nil) |> put_flash(:info, "Token revoked.")} | |
| 60 | + end | |
| 61 | + | |
| 62 | + defp error_message(%Ecto.Changeset{} = cs) do | |
| 63 | + cs | |
| 64 | + |> Ecto.Changeset.traverse_errors(fn {m, _} -> m end) | |
| 65 | + |> Enum.map_join("; ", fn {k, v} -> "#{k} #{Enum.join(v, ", ")}" end) | |
| 66 | + end | |
| 67 | + | |
| 68 | + # Built as a string so the JSON braces don't trip HEEx interpolation. | |
| 69 | + defp curl_example(assigns) do | |
| 70 | + """ | |
| 71 | + curl -X POST #{assigns.api_base}/api/v1/orgs/neiam/repos \\ | |
| 72 | + -H "Authorization: token #{assigns.new_token.secret}" \\ | |
| 73 | + -H "Content-Type: application/json" \\ | |
| 74 | + -d '{"name":"my-repo"}' | |
| 75 | + """ | |
| 76 | + end | |
| 77 | + | |
| 78 | + @impl true | |
| 79 | + def render(assigns) do | |
| 80 | + ~H""" | |
| 81 | + <Layouts.app flash={@flash} current_scope={@current_scope}> | |
| 82 | + <div class="max-w-2xl space-y-6"> | |
| 83 | + <div> | |
| 84 | + <h1 class="text-xl font-semibold">API tokens</h1> | |
| 85 | + <p class="text-sm opacity-70"> | |
| 86 | + Personal access tokens for the REST API (<code>/api/v1</code>). | |
| 87 | + Hand one to a script or tool; revoke any time. | |
| 88 | + </p> | |
| 89 | + </div> | |
| 90 | + | |
| 91 | + <div :if={@new_token} class="rounded border border-success/40 bg-success/10 p-3 space-y-2"> | |
| 92 | + <p class="text-sm font-semibold"> | |
| 93 | + New token “{@new_token.name}” — copy it now, it won't be shown again: | |
| 94 | + </p> | |
| 95 | + <pre class="text-xs bg-black text-green-300 p-2 rounded overflow-auto select-all">{@new_token.secret}</pre> | |
| 96 | + <details class="text-xs"> | |
| 97 | + <summary class="cursor-pointer opacity-80">Create a repo under an org</summary> | |
| 98 | + <pre class="bg-black text-green-300 p-2 rounded overflow-auto select-all mt-1">{curl_example(assigns)}</pre> | |
| 99 | + <p class="opacity-70 mt-1"> | |
| 100 | + Add <code>clone_addr</code> (e.g. a github.com/neiam URL) to import instead of | |
| 101 | + creating empty. | |
| 102 | + </p> | |
| 103 | + </details> | |
| 104 | + </div> | |
| 105 | + | |
| 106 | + <form phx-submit="create_token" class="flex flex-wrap items-end gap-3"> | |
| 107 | + <label class="form-control"> | |
| 108 | + <span class="text-xs opacity-70">Name</span> | |
| 109 | + <input name="name" placeholder="repo-importer" class="input input-sm input-bordered" /> | |
| 110 | + </label> | |
| 111 | + <label :for={s <- @all_scopes} class="label cursor-pointer gap-1 text-sm"> | |
| 112 | + <input type="checkbox" name="scopes[]" value={s} checked class="checkbox checkbox-sm" /> | |
| 113 | + <span class="font-mono">{s}</span> | |
| 114 | + </label> | |
| 115 | + <button class="btn btn-sm btn-primary">Create token</button> | |
| 116 | + </form> | |
| 117 | + | |
| 118 | + <table class="table table-sm"> | |
| 119 | + <thead> | |
| 120 | + <tr><th>Name</th><th>Scopes</th><th>Last used</th><th></th></tr> | |
| 121 | + </thead> | |
| 122 | + <tbody> | |
| 123 | + <tr :for={t <- @tokens}> | |
| 124 | + <td class="font-mono">{t.name}</td> | |
| 125 | + <td class="font-mono text-xs">{Enum.join(t.scopes, ", ")}</td> | |
| 126 | + <td class="text-xs opacity-70"> | |
| 127 | + {t.last_used_at && Calendar.strftime(t.last_used_at, "%Y-%m-%d %H:%M")} | |
| 128 | + </td> | |
| 129 | + <td class="text-right"> | |
| 130 | + <button | |
| 131 | + phx-click="delete_token" | |
| 132 | + phx-value-id={t.id} | |
| 133 | + data-confirm={"Revoke token #{t.name}? Anything using it loses access."} | |
| 134 | + class="btn btn-xs btn-ghost text-error" | |
| 135 | + >Revoke</button> | |
| 136 | + </td> | |
| 137 | + </tr> | |
| 138 | + <tr :if={@tokens == []}> | |
| 139 | + <td colspan="4" class="text-sm opacity-50">No tokens yet.</td> | |
| 140 | + </tr> | |
| 141 | + </tbody> | |
| 142 | + </table> | |
| 143 | + </div> | |
| 144 | + </Layouts.app> | |
| 145 | + """ | |
| 146 | + end | |
| 147 | +end |
modified
lib/git_gud_web/router.ex
+4
−0
@@ -232,6 +232,9 @@ defmodule GitGudWeb.Router do
| 232 | 232 | |
| 233 | 233 | get "/repos/:owner/:name", RepositoryController, :show |
| 234 | 234 | get "/repos/:owner/:name/commits/:sha", RepositoryController, :commit |
| 235 | + | |
| 236 | + # Write endpoints — Personal Access Token auth (handled in-controller). | |
| 237 | + post "/orgs/:org/repos", OrgController, :create_repo | |
| 235 | 238 | end |
| 236 | 239 | |
| 237 | 240 | # Enable LiveDashboard and Swoosh mailbox preview in development |
@@ -261,6 +264,7 @@ defmodule GitGudWeb.Router do
| 261 | 264 | live "/users/settings", UserLive.Settings, :edit |
| 262 | 265 | live "/users/settings/confirm-email/:token", UserLive.Settings, :confirm_email |
| 263 | 266 | live "/users/settings/ssh-keys", SshKeyLive.Index, :index |
| 267 | + live "/users/settings/tokens", PersonalAccessTokenLive.Index, :index | |
| 264 | 268 | live "/users/settings/invites", UserLive.Invites, :index |
| 265 | 269 | live "/users/settings/two-factor", UserLive.TwoFactorSetup, :index |
| 266 | 270 | end |
added
priv/repo/migrations/20260625020000_create_personal_access_tokens.exs
+18
−0
@@ -0,0 +1,18 @@
| 1 | +defmodule GitGud.Repo.Migrations.CreatePersonalAccessTokens do | |
| 2 | + use Ecto.Migration | |
| 3 | + | |
| 4 | + def change do | |
| 5 | + create table(:personal_access_tokens) do | |
| 6 | + add :user_id, references(:users, on_delete: :delete_all), null: false | |
| 7 | + add :name, :string, null: false | |
| 8 | + add :token_hash, :binary, null: false | |
| 9 | + add :scopes, {:array, :string}, null: false, default: [] | |
| 10 | + add :last_used_at, :utc_datetime | |
| 11 | + | |
| 12 | + timestamps(type: :utc_datetime) | |
| 13 | + end | |
| 14 | + | |
| 15 | + create unique_index(:personal_access_tokens, [:user_id, :name]) | |
| 16 | + create unique_index(:personal_access_tokens, [:token_hash]) | |
| 17 | + end | |
| 18 | +end |
Parents: f795f88