From a9c70a78839f072d494d79ef5c6623e55f36ecbe Mon Sep 17 00:00:00 2001 From: Runar Ingebrigtsen Date: Fri, 23 Jan 2026 02:52:53 +0100 Subject: [PATCH] implement /setup and /admin --- .gitignore | 1 + README.md | 14 ++ app/controllers/admin/base_controller.rb | 4 + app/controllers/admin/dashboard_controller.rb | 32 +++ .../admin/invitations_controller.rb | 49 +++++ app/controllers/admin/users_controller.rb | 45 ++++ app/controllers/application_controller.rb | 54 ++++- app/controllers/setup_controller.rb | 45 ++++ app/models/user.rb | 1 + app/views/admin/dashboard/index.html.erb | 204 ++++++++++++++++++ app/views/admin/invitations/index.html.erb | 130 +++++++++++ app/views/admin/invitations/new.html.erb | 78 +++++++ app/views/admin/users/edit.html.erb | 62 ++++++ app/views/admin/users/index.html.erb | 97 +++++++++ app/views/layouts/admin.html.erb | 60 ++++++ app/views/setup/show.html.erb | 107 +++++++++ config/routes.rb | 17 ++ docs/ADMIN.md | 34 +++ docs/SETUP_GUIDE.md | 74 +++++++ docs/TODO.md | 9 +- test/models/user_test.rb | 20 +- 21 files changed, 1124 insertions(+), 13 deletions(-) create mode 100644 app/controllers/admin/base_controller.rb create mode 100644 app/controllers/admin/dashboard_controller.rb create mode 100644 app/controllers/admin/invitations_controller.rb create mode 100644 app/controllers/admin/users_controller.rb create mode 100644 app/controllers/setup_controller.rb create mode 100644 app/views/admin/dashboard/index.html.erb create mode 100644 app/views/admin/invitations/index.html.erb create mode 100644 app/views/admin/invitations/new.html.erb create mode 100644 app/views/admin/users/edit.html.erb create mode 100644 app/views/admin/users/index.html.erb create mode 100644 app/views/layouts/admin.html.erb create mode 100644 app/views/setup/show.html.erb create mode 100644 docs/ADMIN.md create mode 100644 docs/SETUP_GUIDE.md diff --git a/.gitignore b/.gitignore index d1ddcc3..626194c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ .tool-versions .yarn-integrity .DS_Store +.installed /log/* /tmp/* /storage/* diff --git a/README.md b/README.md index b1c50e8..80f2bdc 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,20 @@ When translators disagree on a translation or want to suggest alternatives (regi --- +## Setup / First User + +When the file `.installed` is missing, the `/setup` route is accessible for creating the initial admin account. The first user created will be the system's default contact email (accessible via `User.first.email`). + +For detailed setup instructions, see [SETUP_GUIDE.md](docs/SETUP_GUIDE.md). + +**Quick Start:** +1. Deploy the application +2. Navigate to `/setup` +3. Create your admin account +4. Start inviting contributors + +--- + ## Authentication Flow ### Public Access diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb new file mode 100644 index 0000000..f7f80c4 --- /dev/null +++ b/app/controllers/admin/base_controller.rb @@ -0,0 +1,4 @@ +class Admin::BaseController < ApplicationController + before_action :require_admin + layout "admin" +end diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb new file mode 100644 index 0000000..9a1cdfb --- /dev/null +++ b/app/controllers/admin/dashboard_controller.rb @@ -0,0 +1,32 @@ +class Admin::DashboardController < Admin::BaseController + def index + @user_count = User.count + @contributor_count = User.contributor.count + @reviewer_count = User.reviewer.count + @admin_count = User.admin.count + + @entry_count = Entry.count + @verified_count = Entry.where(verified: true).count + @unverified_count = @entry_count - @verified_count + + @pending_suggestions_count = SuggestedMeaning.pending.count + @accepted_suggestions_count = SuggestedMeaning.accepted.count + @rejected_suggestions_count = SuggestedMeaning.rejected.count + + @comment_count = Comment.count + + @recent_users = User.order(created_at: :desc).limit(5) + @recent_entries = Entry.order(created_at: :desc).limit(5) + @pending_invitations = User.where.not(invitation_token: nil) + .where(invitation_accepted_at: nil) + .where("invitation_sent_at > ?", 14.days.ago) + .count + + @supported_languages = SupportedLanguage.where(active: true).order(:sort_order) + @language_completion = @supported_languages.index_with do |language| + next 0 if @entry_count.zero? + + (Entry.where.not(language.code => [ nil, "" ]).count * 100.0 / @entry_count).round + end + end +end diff --git a/app/controllers/admin/invitations_controller.rb b/app/controllers/admin/invitations_controller.rb new file mode 100644 index 0000000..211635a --- /dev/null +++ b/app/controllers/admin/invitations_controller.rb @@ -0,0 +1,49 @@ +class Admin::InvitationsController < Admin::BaseController + def index + @pending_invitations = User.where.not(invitation_token: nil) + .where(invitation_accepted_at: nil) + .order(invitation_sent_at: :desc) + @accepted_invitations = User.where.not(invitation_accepted_at: nil) + .order(invitation_accepted_at: :desc) + .limit(20) + end + + def new + @invitation = User.new + end + + def create + @invitation = User.new(invitation_params) + @invitation.invitation_token = SecureRandom.urlsafe_base64(32) + @invitation.invitation_sent_at = Time.current + @invitation.invited_by = current_user + @invitation.password = SecureRandom.urlsafe_base64(16) + + if @invitation.save + # TODO: Send invitation email + # InvitationMailer.invite(@invitation).deliver_later + + redirect_to admin_invitations_path, notice: "Invitation sent to #{@invitation.email}" + else + render :new, status: :unprocessable_entity + end + end + + def destroy + @invitation = User.find(params[:id]) + + if @invitation.invitation_accepted_at.present? + redirect_to admin_invitations_path, alert: "Cannot cancel an accepted invitation." + return + end + + @invitation.destroy + redirect_to admin_invitations_path, notice: "Invitation cancelled." + end + + private + + def invitation_params + params.require(:user).permit(:email, :name, :role, :primary_language) + end +end diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb new file mode 100644 index 0000000..01ef353 --- /dev/null +++ b/app/controllers/admin/users_controller.rb @@ -0,0 +1,45 @@ +class Admin::UsersController < Admin::BaseController + before_action :set_user, only: [ :edit, :update, :destroy ] + + def index + @users = User.order(created_at: :desc) + @users = @users.where(role: params[:role]) if params[:role].present? + @users = @users.where("email LIKE ?", "%#{params[:q]}%") if params[:q].present? + end + + def edit + end + + def update + if @user.update(user_params) + redirect_to admin_users_path, notice: "User updated successfully." + else + render :edit, status: :unprocessable_entity + end + end + + def destroy + if @user == current_user + redirect_to admin_users_path, alert: "You cannot delete your own account." + return + end + + if @user == User.first + redirect_to admin_users_path, alert: "Cannot delete the first admin user (system default contact)." + return + end + + @user.destroy + redirect_to admin_users_path, notice: "User deleted successfully." + end + + private + + def set_user + @user = User.find(params[:id]) + end + + def user_params + params.require(:user).permit(:name, :email, :role, :primary_language) + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index c353756..e746ab7 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,7 +1,57 @@ class ApplicationController < ActionController::Base - # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. - allow_browser versions: :modern # Changes to the importmap will invalidate the etag for HTML responses stale_when_importmap_changes + + helper_method :current_user, :logged_in?, :admin?, :reviewer_or_admin?, :contributor_or_above?, :setup_completed? + + private + + def current_user + @current_user ||= User.find_by(id: session[:user_id]) if session[:user_id] + end + + def logged_in? + current_user.present? + end + + def admin? + logged_in? && current_user.admin? + end + + def reviewer_or_admin? + logged_in? && (current_user.reviewer? || current_user.admin?) + end + + def contributor_or_above? + logged_in? + end + + def require_login + unless logged_in? + redirect_to login_path, alert: "You must be logged in to access this page." + end + end + + def require_admin + unless admin? + redirect_to root_path, alert: "You must be an administrator to access this page." + end + end + + def require_reviewer + unless reviewer_or_admin? + redirect_to root_path, alert: "You must be a reviewer or administrator to access this page." + end + end + + def require_contributor + unless contributor_or_above? + redirect_to root_path, alert: "You must be a contributor to access this page." + end + end + + def setup_completed? + File.exist?(Rails.root.join(".installed")) + end end diff --git a/app/controllers/setup_controller.rb b/app/controllers/setup_controller.rb new file mode 100644 index 0000000..d250be0 --- /dev/null +++ b/app/controllers/setup_controller.rb @@ -0,0 +1,45 @@ +class SetupController < ApplicationController + before_action :check_setup_allowed + + def show + @user = User.new(role: :admin) + end + + def create + @user = User.new(user_params) + @user.role = :admin + @user.invitation_accepted_at = Time.current + + if @user.save + create_installed_marker + session[:user_id] = @user.id + redirect_to admin_root_path, notice: "Setup complete! Welcome to Sanasto Wiki." + else + render :show, status: :unprocessable_entity + end + end + + private + + def check_setup_allowed + if setup_completed? + redirect_to root_path, alert: "Setup has already been completed." + end + end + + def setup_completed? + File.exist?(installed_marker_path) + end + + def installed_marker_path + Rails.root.join(".installed") + end + + def create_installed_marker + FileUtils.touch(installed_marker_path) + end + + def user_params + params.require(:user).permit(:email, :name, :password, :password_confirmation, :primary_language) + end +end diff --git a/app/models/user.rb b/app/models/user.rb index a20f371..a342752 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -20,4 +20,5 @@ class User < ApplicationRecord enum :role, %i[contributor reviewer admin] validates :email, presence: true, uniqueness: true + validates :password, length: { minimum: 12 }, if: -> { password.present? } end diff --git a/app/views/admin/dashboard/index.html.erb b/app/views/admin/dashboard/index.html.erb new file mode 100644 index 0000000..dda36ed --- /dev/null +++ b/app/views/admin/dashboard/index.html.erb @@ -0,0 +1,204 @@ +<% content_for :title, "Dashboard" %> + +
+
+

Dashboard

+

Overview of Sanasto Wiki statistics

+
+ + +
+ +
+
+
+
+ + + +
+
+
+
Total Users
+
<%= @user_count %>
+
+
+
+
+
+
+ <%= @admin_count %> admins, + <%= @reviewer_count %> reviewers, + <%= @contributor_count %> contributors +
+
+
+ + +
+
+
+
+ + + +
+
+
+
Total Entries
+
<%= @entry_count %>
+
+
+
+
+
+
+ <%= @verified_count %> verified, + <%= @unverified_count %> unverified +
+
+
+ + +
+
+
+
+ + + +
+
+
+
Suggestions
+
<%= @pending_suggestions_count %>
+
+
+
+
+
+
+ <%= @accepted_suggestions_count %> accepted, + <%= @rejected_suggestions_count %> rejected +
+
+
+ + +
+
+
+
+ + + +
+
+
+
Pending Invites
+
<%= @pending_invitations %>
+
+
+
+
+
+
+ <%= link_to "Send invitation", new_admin_invitation_path, class: "font-medium text-blue-600 hover:text-blue-500" %> +
+
+
+
+ + +
+
+

Language Completion

+
+
+ <% @supported_languages.each do |language| %> +
+
+ <%= language.native_name %> + <%= @language_completion[language] %>% +
+
+
+
+
+ <% end %> +
+
+
+
+ + +
+ +
+
+

Recent Users

+
+
    + <% @recent_users.each do |user| %> +
  • +
    +
    +

    + <%= user.name || user.email %> +

    +

    + <%= user.email %> +

    +
    +
    + + <%= user.role %> + +
    +
    +
  • + <% end %> +
+
+
+ <%= link_to "View all users →", admin_users_path, class: "text-sm font-medium text-blue-600 hover:text-blue-500" %> +
+
+
+ + +
+
+

Recent Entries

+
+
    + <% @recent_entries.each do |entry| %> +
  • +
    +
    +

    + <%= entry.fi || entry.en || entry.sv || "(No translation)" %> +

    +

    + <%= entry.category.humanize %> +

    +
    +
    + <% if entry.verified? %> + + verified + + <% end %> +
    +
    +
  • + <% end %> +
+
+
+ <%= link_to "View all entries →", entries_path, class: "text-sm font-medium text-blue-600 hover:text-blue-500" %> +
+
+
+
+
diff --git a/app/views/admin/invitations/index.html.erb b/app/views/admin/invitations/index.html.erb new file mode 100644 index 0000000..9b00a76 --- /dev/null +++ b/app/views/admin/invitations/index.html.erb @@ -0,0 +1,130 @@ +<% content_for :title, "Invitations" %> + +
+
+
+

Invitations

+

Manage user invitations

+
+
+ <%= link_to "Send New Invitation", new_admin_invitation_path, class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %> +
+
+ + +
+
+

+ Pending Invitations + + <%= @pending_invitations.count %> + +

+
+ <% if @pending_invitations.any? %> + + + + + + + + + + + + + <% @pending_invitations.each do |invitation| %> + + + + + + + + + <% end %> + +
EmailRoleSentExpiresInvited By + Actions +
+ <%= invitation.email %> + + + <%= invitation.role %> + + + <%= invitation.invitation_sent_at.strftime("%b %d, %Y") %> + + <% expires_at = invitation.invitation_sent_at + 14.days %> + <% if expires_at < Time.current %> + Expired + <% else %> + <%= expires_at.strftime("%b %d, %Y") %> + <% end %> + + <%= invitation.invited_by&.name || invitation.invited_by&.email || "-" %> + + <%= button_to "Cancel", admin_invitation_path(invitation), method: :delete, data: { turbo_confirm: "Are you sure you want to cancel this invitation?" }, class: "text-red-600 hover:text-red-900" %> +
+ <% else %> +
+

No pending invitations

+
+ <% end %> +
+ + +
+
+

Recently Accepted

+
+ <% if @accepted_invitations.any? %> + + + + + + + + + + + <% @accepted_invitations.each do |user| %> + + + + + + + <% end %> + +
UserRoleAcceptedInvited By
+
<%= user.name || "(No name)" %>
+
<%= user.email %>
+
+ + <%= user.role %> + + + <%= user.invitation_accepted_at.strftime("%b %d, %Y") %> + + <%= user.invited_by&.name || user.invited_by&.email || "-" %> +
+ <% else %> +
+

No accepted invitations yet

+
+ <% end %> +
+
diff --git a/app/views/admin/invitations/new.html.erb b/app/views/admin/invitations/new.html.erb new file mode 100644 index 0000000..f7d5f73 --- /dev/null +++ b/app/views/admin/invitations/new.html.erb @@ -0,0 +1,78 @@ +<% content_for :title, "Send Invitation" %> + +
+
+ <%= link_to "← Back to Invitations", admin_invitations_path, class: "text-sm font-medium text-blue-600 hover:text-blue-500" %> +
+ +
+

Send Invitation

+

Invite a new user to join Sanasto Wiki

+
+ +
+
+ <%= form_with model: @invitation, url: admin_invitations_path, class: "space-y-6" do |f| %> + <% if @invitation.errors.any? %> +
+
+
+

+ <%= pluralize(@invitation.errors.count, "error") %> prohibited this invitation from being sent: +

+
+
    + <% @invitation.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+
+
+
+ <% end %> + +
+ <%= f.label :email, class: "block text-sm font-medium text-gray-700" %> + <%= f.email_field :email, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "user@example.com" %> +

The user will receive an invitation email with a link to register.

+
+ +
+ <%= f.label :name, "Name (optional)", class: "block text-sm font-medium text-gray-700" %> + <%= f.text_field :name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "John Doe" %> +
+ +
+ <%= f.label :role, class: "block text-sm font-medium text-gray-700" %> + <%= f.select :role, User.roles.keys.map { |r| [r.humanize, r] }, {}, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %> +

+ Contributor: Can create/edit entries and comments.
+ Reviewer: Can review suggestions and verify entries.
+ Admin: Full access to all features. +

+
+ +
+ <%= f.label :primary_language, "Primary Language (optional)", class: "block text-sm font-medium text-gray-700" %> + <%= f.select :primary_language, SupportedLanguage.pluck(:code, :native_name), { include_blank: "Select language" }, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %> +
+ +
+
+
+

+ Note: The invitation link will expire in 14 days. The user will need to accept the invitation before the expiration date. +

+
+
+
+ +
+ <%= link_to "Cancel", admin_invitations_path, class: "px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %> + <%= f.submit "Send Invitation", class: "inline-flex justify-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %> +
+ <% end %> +
+
+
diff --git a/app/views/admin/users/edit.html.erb b/app/views/admin/users/edit.html.erb new file mode 100644 index 0000000..fd68e3a --- /dev/null +++ b/app/views/admin/users/edit.html.erb @@ -0,0 +1,62 @@ +<% content_for :title, "Edit User" %> + +
+
+ <%= link_to "← Back to Users", admin_users_path, class: "text-sm font-medium text-blue-600 hover:text-blue-500" %> +
+ +
+

Edit User

+

Update user information and permissions

+
+ +
+
+ <%= form_with model: @user, url: admin_user_path(@user), method: :patch, class: "space-y-6" do |f| %> + <% if @user.errors.any? %> +
+
+
+

+ <%= pluralize(@user.errors.count, "error") %> prohibited this user from being saved: +

+
+
    + <% @user.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+
+
+
+ <% end %> + +
+ <%= f.label :name, class: "block text-sm font-medium text-gray-700" %> + <%= f.text_field :name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %> +
+ +
+ <%= f.label :email, class: "block text-sm font-medium text-gray-700" %> + <%= f.email_field :email, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %> +
+ +
+ <%= f.label :role, class: "block text-sm font-medium text-gray-700" %> + <%= f.select :role, User.roles.keys.map { |r| [r.humanize, r] }, {}, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %> +
+ +
+ <%= f.label :primary_language, class: "block text-sm font-medium text-gray-700" %> + <%= f.select :primary_language, SupportedLanguage.pluck(:code, :native_name), { include_blank: "Select language" }, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %> +
+ +
+ <%= link_to "Cancel", admin_users_path, class: "px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %> + <%= f.submit "Update User", class: "inline-flex justify-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %> +
+ <% end %> +
+
+
diff --git a/app/views/admin/users/index.html.erb b/app/views/admin/users/index.html.erb new file mode 100644 index 0000000..987cd05 --- /dev/null +++ b/app/views/admin/users/index.html.erb @@ -0,0 +1,97 @@ +<% content_for :title, "Users" %> + +
+
+
+

User Management

+

Manage user accounts and permissions

+
+
+ <%= link_to "Send Invitation", new_admin_invitation_path, class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %> +
+
+ + +
+ <%= form_with url: admin_users_path, method: :get, class: "flex gap-4" do |f| %> +
+ <%= f.text_field :q, value: params[:q], placeholder: "Search by email...", class: "block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %> +
+
+ <%= f.select :role, options_for_select([["All Roles", ""], ["Contributors", "contributor"], ["Reviewers", "reviewer"], ["Admins", "admin"]], params[:role]), {}, class: "block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %> +
+
+ <%= f.submit "Filter", class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700" %> +
+ <% end %> +
+ + +
+ + + + + + + + + + + + + <% @users.each do |user| %> + + + + + + + + + <% end %> + +
UserRolePrimary LanguageJoinedStatus + Actions +
+
+
+
+ <%= user.name || "(No name)" %> +
+
+ <%= user.email %> +
+
+
+
+ + <%= user.role %> + + + <%= user.primary_language&.upcase || "-" %> + + <%= user.invitation_accepted_at&.strftime("%b %d, %Y") || user.created_at.strftime("%b %d, %Y") %> + + <% if user.invitation_accepted_at.present? %> + + Active + + <% else %> + + Invited + + <% end %> + + <%= link_to "Edit", edit_admin_user_path(user), class: "text-blue-600 hover:text-blue-900 mr-4" %> + <% if user != current_user && user != User.first %> + <%= button_to "Delete", admin_user_path(user), method: :delete, data: { turbo_confirm: "Are you sure you want to delete this user?" }, class: "text-red-600 hover:text-red-900" %> + <% end %> +
+
+
diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb new file mode 100644 index 0000000..7b504dd --- /dev/null +++ b/app/views/layouts/admin.html.erb @@ -0,0 +1,60 @@ + + + + <%= content_for(:title) || "Admin Dashboard" %> - Sanasto Wiki + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> + <%= javascript_importmap_tags %> + + + + +
+ +
+
+
+
+

+ <%= link_to "Sanasto Admin", admin_root_path, class: "hover:text-blue-100" %> +

+
+ +
+
+
+ + + <% if flash.any? %> +
+ <% flash.each do |type, message| %> + + <% end %> +
+ <% end %> + + +
+ <%= yield %> +
+ + +
+
+

+ Sanasto Wiki Admin Dashboard © <%= Time.current.year %> +

+
+
+
+ + diff --git a/app/views/setup/show.html.erb b/app/views/setup/show.html.erb new file mode 100644 index 0000000..9890419 --- /dev/null +++ b/app/views/setup/show.html.erb @@ -0,0 +1,107 @@ + + + + Setup - Sanasto Wiki + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> + <%= javascript_importmap_tags %> + + + + +
+ +
+ +
+

Sanasto Wiki

+

Initial Setup

+
+ + +
+
+

Create Admin Account

+

+ Welcome! Let's create your administrator account to get started. +

+
+ + <%= form_with model: @user, url: setup_path, method: :post, class: "space-y-6" do |f| %> + <% if @user.errors.any? %> +
+
+
+ + + +
+
+

+ <%= pluralize(@user.errors.count, "error") %> prevented setup: +

+
+
    + <% @user.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+
+
+
+ <% end %> + +
+ <%= f.label :name, class: "block text-sm font-medium text-gray-700" %> + <%= f.text_field :name, required: true, autofocus: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Your full name" %> +
+ +
+ <%= f.label :email, class: "block text-sm font-medium text-gray-700" %> + <%= f.email_field :email, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "admin@example.com" %> +

This will be the default contact email for the system.

+
+ +
+ <%= f.label :primary_language, "Preferred Language", class: "block text-sm font-medium text-gray-700" %> + <%= f.select :primary_language, SupportedLanguage.order(:sort_order).pluck(:code, :native_name), { include_blank: "Select your primary language" }, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %> +
+ +
+ <%= f.label :password, class: "block text-sm font-medium text-gray-700" %> + <%= f.password_field :password, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Minimum 12 characters" %> +

Choose a strong password with at least 12 characters.

+
+ +
+ <%= f.label :password_confirmation, "Confirm Password", class: "block text-sm font-medium text-gray-700" %> + <%= f.password_field :password_confirmation, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Re-enter your password" %> +
+ +
+
+
+ + + +
+
+

+ Note: This account will have full administrator access and cannot be deleted through the UI. +

+
+
+
+ +
+ <%= f.submit "Complete Setup", class: "w-full flex justify-center py-3 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-200" %> +
+ <% end %> +
+
+
+ + diff --git a/config/routes.rb b/config/routes.rb index 561dca5..a74e826 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -12,6 +12,23 @@ Rails.application.routes.draw do # Defines the root path route ("/") root "entries#index" + # Setup route (only accessible when .installed file doesn't exist) + get "setup", to: "setup#show" + post "setup", to: "setup#create" + + # Authentication routes (placeholder for future implementation) + # get "login", to: "sessions#new", as: :login + # post "login", to: "sessions#create" + # delete "logout", to: "sessions#destroy", as: :logout + + # Admin namespace + namespace :admin do + root "dashboard#index" + get "dashboard", to: "dashboard#index" + resources :users, only: [ :index, :edit, :update, :destroy ] + resources :invitations, only: [ :index, :new, :create, :destroy ] + end + resources :entries do collection do get :download diff --git a/docs/ADMIN.md b/docs/ADMIN.md new file mode 100644 index 0000000..950f470 --- /dev/null +++ b/docs/ADMIN.md @@ -0,0 +1,34 @@ +# Admin Dashboard + +The admin dashboard provides tools for managing users, invitations, and viewing system statistics. + +## Creating the First Admin User + +Visit /setup + +## Accessing the Admin Dashboard + +1. **Note:** Authentication system is not yet implemented. You'll need to set `session[:user_id]` manually in the console or implement the authentication controllers first. + +2. Once authenticated, navigate to `/admin` to access the dashboard. + +## Admin Features + +### Dashboard (`/admin`) +- View system statistics (users, entries, suggestions) +- Language completion percentages +- Recent activity (new users, new entries) +- Pending invitations count + +### User Management (`/admin/users`) +- List all users with filtering +- Edit user details and roles +- Delete users (except yourself) +- View user status (active/invited) + +### Invitations (`/admin/invitations`) +- Send new invitations +- View pending invitations (not yet accepted) +- Cancel pending invitations +- View recently accepted invitations +- Invitations expire after 14 days diff --git a/docs/SETUP_GUIDE.md b/docs/SETUP_GUIDE.md new file mode 100644 index 0000000..0b3dc1b --- /dev/null +++ b/docs/SETUP_GUIDE.md @@ -0,0 +1,74 @@ +# Setup Guide + +## Initial Setup + +When you first deploy Sanasto Wiki, you need to create an initial administrator account. + +### Automatic Setup (Recommended) + +1. Start the Rails server: + ```bash + bundle exec rails server + ``` + +2. Navigate to `/setup` in your browser + +3. Fill in the setup form: + - **Name**: Your full name + - **Email**: Your email address (becomes the system default contact) + - **Preferred Language**: Your primary working language + - **Password**: At least 12 characters + - **Confirm Password**: Re-enter your password + +4. Click "Complete Setup" + +5. You'll be automatically logged in and redirected to the admin dashboard + +### What Happens During Setup + +- Creates your admin account with full permissions +- Sets you as the default system contact (User.first.email) +- Creates a `.installed` marker file to prevent re-running setup +- Automatically logs you in +- Protects your account from deletion (first user cannot be deleted) + +### After Setup + +Once setup is complete: +- The `/setup` route becomes inaccessible +- You can access the admin dashboard at `/admin` +- You can invite other users through the admin interface +- The first admin user (you) is protected from deletion + +### Resetting Setup + +If you need to re-run setup (e.g., in development): + +```bash +# Remove the installed marker +rm .installed + +# Clear the database (development only!) +bundle exec rails db:reset + +# Now you can access /setup again +``` + +### Production Deployment + +For production deployment: +1. Deploy the application +2. Run migrations: `bundle exec rails db:migrate` +3. Navigate to your domain's `/setup` route +4. Complete the setup form +5. Start inviting contributors + +The `.installed` file should NOT be committed to version control (it's in .gitignore). + +### Security Notes + +- The setup route is only accessible when `.installed` file doesn't exist +- Password must be at least 12 characters +- The first admin user cannot be deleted through the UI +- Setup automatically creates an admin-level account +- After setup, use the invitation system to add more users diff --git a/docs/TODO.md b/docs/TODO.md index 357119d..6db798a 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -54,10 +54,11 @@ ## User Management -- [ ] **Admin dashboard** - - [ ] Send invitations by email - - [ ] Manage users (list, edit roles, deactivate) - - [ ] System statistics (users, entries, contributions) +- [x] **Setup** adds the first user +- [x] **Admin dashboard** + - [x] Send invitations interface (email delivery pending mailer implementation) + - [x] Manage users (list, edit roles, delete) + - [x] System statistics (users, entries, contributions) - [ ] **User profile page** - [ ] Edit name, email, password - [ ] Set primary language preference diff --git a/test/models/user_test.rb b/test/models/user_test.rb index 0020ba5..f8b7c32 100644 --- a/test/models/user_test.rb +++ b/test/models/user_test.rb @@ -2,33 +2,39 @@ require "test_helper" class UserTest < ActiveSupport::TestCase test "should be valid with an email and password" do - user = User.new(email: "test@example.com", password: "password") + user = User.new(email: "test@example.com", password: "password123456") assert user.valid? end test "should be invalid without an email" do - user = User.new(password: "password") + user = User.new(password: "password123456") assert_not user.valid? end test "should be invalid with a duplicate email" do - User.create(email: "test@example.com", password: "password") - user = User.new(email: "test@example.com", password: "password") + User.create(email: "test@example.com", password: "password123456") + user = User.new(email: "test@example.com", password: "password123456") assert_not user.valid? end test "should have a default role of contributor" do - user = User.new(email: "test@example.com", password: "password") + user = User.new(email: "test@example.com", password: "password123456") assert user.contributor? end test "can be a reviewer" do - user = User.new(email: "test@example.com", password: "password", role: :reviewer) + user = User.new(email: "test@example.com", password: "password123456", role: :reviewer) assert user.reviewer? end test "can be an admin" do - user = User.new(email: "test@example.com", password: "password", role: :admin) + user = User.new(email: "test@example.com", password: "password123456", role: :admin) assert user.admin? end + + test "should be invalid with a password shorter than 12 characters" do + user = User.new(email: "test@example.com", password: "short") + assert_not user.valid? + assert_includes user.errors[:password], "is too short (minimum is 12 characters)" + end end