Skip to content

Commit 314b6e6

Browse files
committed
Add Beacon admin interface with login, dashboard, users, settings
Login System: - LoginLive: standalone LiveView with OIDC provider buttons + dev mode email/password form - LoginLayout: minimal centered layout for pre-auth pages - Login route in separate live_session (no admin sidebar) Beacon Admin Pages (super_admin only): - DashboardLive: overview of all sites with page/layout/component counts, user count, quick links to admin sections - UsersLive: full user management — create, edit, delete, role assignment with role picker and site picker for site-scoped roles - SettingsLive: global platform settings — AI crawler policy, default meta tags, site name, title template - GlobalTemplateTypesLive: template type management with site=nil scope Client Auth API: - Client.Auth module wrapping Beacon.Auth functions Navigation: - "Beacon Admin" section in sidebar nav for super admins - Routes at /beacon, /beacon/users, /beacon/settings, /beacon/template_types
1 parent c7692af commit 314b6e6

9 files changed

Lines changed: 1132 additions & 3 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
defmodule Beacon.LiveAdmin.Client.Auth do
2+
@moduledoc false
3+
4+
@doc """
5+
List all users.
6+
Auth functions are not site-scoped but we accept site for API consistency.
7+
"""
8+
def list_users(_site, opts \\ []) do
9+
Beacon.Auth.list_users(opts)
10+
end
11+
12+
def get_user(_site, id) do
13+
Beacon.Auth.get_user(id)
14+
end
15+
16+
def create_user(_site, attrs) do
17+
Beacon.Auth.create_user(attrs)
18+
end
19+
20+
def update_user(_site, user, attrs) do
21+
Beacon.Auth.update_user(user, attrs)
22+
end
23+
24+
def delete_user(_site, user) do
25+
Beacon.Auth.delete_user(user)
26+
end
27+
28+
def assign_role(_site, user, role, role_site) do
29+
Beacon.Auth.assign_role(user, role, role_site)
30+
end
31+
32+
def revoke_role(_site, user, role, role_site) do
33+
Beacon.Auth.revoke_role(user, role, role_site)
34+
end
35+
36+
def list_roles(_site, user) do
37+
Beacon.Auth.list_roles(user)
38+
end
39+
40+
def has_role?(_site, user, role, role_site) do
41+
Beacon.Auth.has_role?(user, role, role_site)
42+
end
43+
44+
def is_super_admin?(_site, user) do
45+
Beacon.Auth.is_super_admin?(user)
46+
end
47+
48+
def can_access_site?(_site, user, target_site) do
49+
Beacon.Auth.can_access_site?(user, target_site)
50+
end
51+
end
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
defmodule Beacon.LiveAdmin.Auth.LoginLayout do
2+
@moduledoc false
3+
4+
use Phoenix.Component
5+
6+
import Phoenix.HTML, only: [raw: 1]
7+
8+
def render(assigns) do
9+
~H"""
10+
<!DOCTYPE html>
11+
<html lang="en">
12+
<head>
13+
<meta charset="utf-8" />
14+
<meta name="viewport" content="width=device-width, initial-scale=1" />
15+
<meta name="csrf-token" content={Phoenix.Controller.get_csrf_token()} />
16+
<title>Beacon Admin Login</title>
17+
<style>
18+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
19+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif; background-color: #f3f4f6; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
20+
.login-card { background: white; border-radius: 12px; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1); padding: 2.5rem; width: 100%; max-width: 400px; }
21+
.login-header { text-align: center; margin-bottom: 2rem; }
22+
.login-logo { display: inline-flex; align-items: center; justify-content: center; width: 48px; height: 48px; background-color: #4f46e5; border-radius: 12px; margin-bottom: 1rem; }
23+
.login-logo svg { width: 28px; height: 28px; color: white; }
24+
.login-title { font-size: 1.5rem; font-weight: 700; color: #111827; }
25+
.login-subtitle { font-size: 0.875rem; color: #6b7280; margin-top: 0.25rem; }
26+
.provider-btn { display: flex; align-items: center; justify-content: center; gap: 0.5rem; width: 100%; padding: 0.75rem 1rem; border: 1px solid #d1d5db; border-radius: 8px; background: white; font-size: 0.875rem; font-weight: 500; color: #374151; cursor: pointer; transition: all 0.15s; text-decoration: none; margin-bottom: 0.75rem; }
27+
.provider-btn:hover { background-color: #f9fafb; border-color: #9ca3af; }
28+
.divider { display: flex; align-items: center; gap: 1rem; margin: 1.5rem 0; color: #9ca3af; font-size: 0.75rem; }
29+
.divider::before, .divider::after { content: ""; flex: 1; height: 1px; background: #e5e7eb; }
30+
.form-group { margin-bottom: 1rem; }
31+
.form-label { display: block; font-size: 0.875rem; font-weight: 500; color: #374151; margin-bottom: 0.375rem; }
32+
.form-input { width: 100%; padding: 0.625rem 0.75rem; border: 1px solid #d1d5db; border-radius: 8px; font-size: 0.875rem; outline: none; transition: border-color 0.15s; }
33+
.form-input:focus { border-color: #4f46e5; box-shadow: 0 0 0 3px rgba(79,70,229,0.1); }
34+
.submit-btn { width: 100%; padding: 0.75rem 1rem; background-color: #4f46e5; color: white; border: none; border-radius: 8px; font-size: 0.875rem; font-weight: 600; cursor: pointer; transition: background-color 0.15s; }
35+
.submit-btn:hover { background-color: #4338ca; }
36+
.flash-error { background-color: #fef2f2; border: 1px solid #fecaca; color: #991b1b; padding: 0.75rem 1rem; border-radius: 8px; font-size: 0.875rem; margin-bottom: 1.5rem; }
37+
.flash-info { background-color: #ecfdf5; border: 1px solid #a7f3d0; color: #065f46; padding: 0.75rem 1rem; border-radius: 8px; font-size: 0.875rem; margin-bottom: 1.5rem; }
38+
</style>
39+
</head>
40+
<body>
41+
<%= @inner_content %>
42+
</body>
43+
</html>
44+
"""
45+
end
46+
end
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
defmodule Beacon.LiveAdmin.Auth.LoginLive do
2+
@moduledoc false
3+
4+
use Phoenix.LiveView, layout: {Beacon.LiveAdmin.Auth.LoginLayout, :render}
5+
6+
@impl true
7+
def mount(_params, _session, socket) do
8+
dev_mode? = dev_mode?()
9+
providers = providers()
10+
11+
{:ok,
12+
socket
13+
|> assign(:dev_mode?, dev_mode?)
14+
|> assign(:providers, providers)
15+
|> assign(:form_data, %{"email" => "", "password" => ""})}
16+
end
17+
18+
@impl true
19+
def handle_event("validate", %{"login" => params}, socket) do
20+
{:noreply, assign(socket, :form_data, params)}
21+
end
22+
23+
def handle_event("dev_login", %{"login" => params}, socket) do
24+
email = params["email"]
25+
password = params["password"]
26+
27+
case dev_login(email, password) do
28+
{:ok, token} ->
29+
{:noreply,
30+
socket
31+
|> put_flash(:info, "Login successful")
32+
|> redirect(to: admin_path(socket) <> "?token=" <> Base.url_encode64(token))}
33+
34+
{:error, reason} ->
35+
{:noreply, put_flash(socket, :error, reason)}
36+
end
37+
end
38+
39+
defp dev_login(email, _password) do
40+
with true <- dev_mode?(),
41+
user when not is_nil(user) <- Beacon.Auth.get_user_by_email(email),
42+
token when is_binary(token) <- Beacon.Auth.create_session(user) do
43+
{:ok, token}
44+
else
45+
false -> {:error, "Dev mode is not enabled"}
46+
nil -> {:error, "No user found with that email"}
47+
_ -> {:error, "Login failed"}
48+
end
49+
end
50+
51+
defp dev_mode? do
52+
Code.ensure_loaded?(Beacon.Auth.Config) and Beacon.Auth.Config.dev_mode?()
53+
end
54+
55+
defp providers do
56+
if Code.ensure_loaded?(Beacon.Auth.Config) do
57+
Beacon.Auth.Config.providers()
58+
else
59+
[]
60+
end
61+
end
62+
63+
defp admin_path(socket) do
64+
router = socket.router
65+
prefix = router.__beacon_live_admin_prefix__()
66+
prefix || "/admin"
67+
end
68+
69+
@impl true
70+
def render(assigns) do
71+
~H"""
72+
<div class="login-card">
73+
<div class="login-header">
74+
<div class="login-logo">
75+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
76+
<circle cx="12" cy="12" r="3" />
77+
<path d="M12 2v4m0 12v4m10-10h-4M6 12H2m15.07-7.07l-2.83 2.83M9.76 14.24l-2.83 2.83m11.14 0l-2.83-2.83M9.76 9.76L6.93 6.93" />
78+
</svg>
79+
</div>
80+
<h1 class="login-title">Beacon Admin</h1>
81+
<p class="login-subtitle">Sign in to manage your sites</p>
82+
</div>
83+
84+
<%= if Phoenix.Flash.get(@flash, :error) do %>
85+
<div class="flash-error"><%= Phoenix.Flash.get(@flash, :error) %></div>
86+
<% end %>
87+
88+
<%= if Phoenix.Flash.get(@flash, :info) do %>
89+
<div class="flash-info"><%= Phoenix.Flash.get(@flash, :info) %></div>
90+
<% end %>
91+
92+
<%= if @providers != [] do %>
93+
<%= for provider <- @providers do %>
94+
<a href={admin_path(@socket) <> "/auth/#{provider.id}"} class="provider-btn">
95+
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
96+
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4" /><polyline points="10 17 15 12 10 7" /><line x1="15" y1="12" x2="3" y2="12" />
97+
</svg>
98+
Sign in with <%= provider.name || provider.id %>
99+
</a>
100+
<% end %>
101+
<% end %>
102+
103+
<%= if @dev_mode? do %>
104+
<%= if @providers != [] do %>
105+
<div class="divider">or</div>
106+
<% end %>
107+
108+
<form phx-submit="dev_login" phx-change="validate">
109+
<div class="form-group">
110+
<label class="form-label" for="login_email">Email</label>
111+
<input
112+
type="email"
113+
id="login_email"
114+
name="login[email]"
115+
value={@form_data["email"]}
116+
placeholder="admin@example.com"
117+
class="form-input"
118+
required
119+
/>
120+
</div>
121+
<div class="form-group">
122+
<label class="form-label" for="login_password">Password</label>
123+
<input
124+
type="password"
125+
id="login_password"
126+
name="login[password]"
127+
value={@form_data["password"]}
128+
placeholder="password"
129+
class="form-input"
130+
/>
131+
</div>
132+
<button type="submit" class="submit-btn">
133+
Sign in (Dev Mode)
134+
</button>
135+
</form>
136+
<% end %>
137+
138+
<%= if @providers == [] and not @dev_mode? do %>
139+
<p style="text-align: center; color: #6b7280; font-size: 0.875rem;">
140+
No authentication providers configured.
141+
Please configure OIDC providers or enable dev mode.
142+
</p>
143+
<% end %>
144+
</div>
145+
"""
146+
end
147+
end
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
defmodule Beacon.LiveAdmin.BeaconAdmin.DashboardLive do
2+
@moduledoc false
3+
4+
use Beacon.LiveAdmin.PageBuilder
5+
6+
alias Beacon.LiveAdmin.Cluster
7+
alias Beacon.LiveAdmin.Client.Content
8+
9+
@impl true
10+
def menu_link("/beacon", :dashboard), do: {:root, "Beacon Admin"}
11+
def menu_link(_, _), do: :skip
12+
13+
@impl true
14+
def handle_params(_params, _url, socket) do
15+
sites = Cluster.running_sites()
16+
17+
site_stats =
18+
Enum.map(sites, fn site ->
19+
page_count =
20+
try do
21+
Content.count_pages(site)
22+
rescue
23+
_ -> 0
24+
end
25+
26+
layout_count =
27+
try do
28+
Content.count_layouts(site)
29+
rescue
30+
_ -> 0
31+
end
32+
33+
component_count =
34+
try do
35+
Content.count_components(site)
36+
rescue
37+
_ -> 0
38+
end
39+
40+
%{
41+
site: site,
42+
page_count: page_count,
43+
layout_count: layout_count,
44+
component_count: component_count
45+
}
46+
end)
47+
48+
user_count =
49+
try do
50+
length(Beacon.Auth.list_users())
51+
rescue
52+
_ -> 0
53+
end
54+
55+
{:noreply,
56+
socket
57+
|> assign(:sites, sites)
58+
|> assign(:site_stats, site_stats)
59+
|> assign(:user_count, user_count)
60+
|> assign(page_title: "Beacon Admin")}
61+
end
62+
63+
@impl true
64+
def render(assigns) do
65+
~H"""
66+
<div class="mx-auto max-w-6xl py-6 px-4">
67+
<div class="mb-8">
68+
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Beacon Admin</h1>
69+
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Platform-wide overview and management</p>
70+
</div>
71+
72+
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8">
73+
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-5">
74+
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">Sites</div>
75+
<div class="mt-1 text-3xl font-bold text-gray-900 dark:text-gray-100"><%= length(@sites) %></div>
76+
</div>
77+
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-5">
78+
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">Users</div>
79+
<div class="mt-1 text-3xl font-bold text-gray-900 dark:text-gray-100"><%= @user_count %></div>
80+
</div>
81+
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-5">
82+
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">Total Pages</div>
83+
<div class="mt-1 text-3xl font-bold text-gray-900 dark:text-gray-100"><%= Enum.reduce(@site_stats, 0, &(&1.page_count + &2)) %></div>
84+
</div>
85+
</div>
86+
87+
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Sites</h2>
88+
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
89+
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
90+
<thead class="bg-gray-50 dark:bg-gray-900">
91+
<tr>
92+
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Site</th>
93+
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Pages</th>
94+
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Layouts</th>
95+
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Components</th>
96+
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Actions</th>
97+
</tr>
98+
</thead>
99+
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
100+
<%= for stat <- @site_stats do %>
101+
<tr>
102+
<td class="px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100"><%= stat.site %></td>
103+
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400"><%= stat.page_count %></td>
104+
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400"><%= stat.layout_count %></td>
105+
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400"><%= stat.component_count %></td>
106+
<td class="px-4 py-3 text-right">
107+
<.link
108+
href={beacon_live_admin_path(@socket, stat.site, "/pages")}
109+
class="text-indigo-600 dark:text-indigo-400 hover:text-indigo-900 dark:hover:text-indigo-300 text-sm font-medium"
110+
>
111+
Manage
112+
</.link>
113+
</td>
114+
</tr>
115+
<% end %>
116+
<%= if @site_stats == [] do %>
117+
<tr>
118+
<td colspan="5" class="px-4 py-8 text-center text-sm text-gray-500 dark:text-gray-400">No sites running</td>
119+
</tr>
120+
<% end %>
121+
</tbody>
122+
</table>
123+
</div>
124+
125+
<div class="mt-8 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
126+
<.link
127+
href={beacon_live_admin_path(@socket, @beacon_page.site, "/beacon/users")}
128+
class="block p-5 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-indigo-300 dark:hover:border-indigo-500/50 transition-all"
129+
>
130+
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">User Management</div>
131+
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Create, edit, and manage users and their roles</p>
132+
</.link>
133+
<.link
134+
href={beacon_live_admin_path(@socket, @beacon_page.site, "/beacon/settings")}
135+
class="block p-5 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-indigo-300 dark:hover:border-indigo-500/50 transition-all"
136+
>
137+
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">Global Settings</div>
138+
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Configure platform-wide defaults and policies</p>
139+
</.link>
140+
<.link
141+
href={beacon_live_admin_path(@socket, @beacon_page.site, "/beacon/template_types")}
142+
class="block p-5 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-indigo-300 dark:hover:border-indigo-500/50 transition-all"
143+
>
144+
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">Global Template Types</div>
145+
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Manage template types available to all sites</p>
146+
</.link>
147+
</div>
148+
</div>
149+
"""
150+
end
151+
end

0 commit comments

Comments
 (0)