Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a79b27020a | ||
|
|
9a814f1aa1 | ||
|
|
b3726e0777 | ||
|
|
a7713b962f | ||
|
|
4fdebc8bf8 | ||
|
|
faf87fe44f | ||
|
|
396e649960 | ||
|
|
35c29749fb | ||
|
|
dea0ef508a | ||
|
|
965e8cdffe |
@@ -53,7 +53,7 @@ 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`).
|
||||
When setup has not been completed, the `/setup` route is accessible for creating the initial admin account. Completion is tracked in the database. 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).
|
||||
|
||||
@@ -94,112 +94,8 @@ For detailed setup instructions, see [SETUP_GUIDE.md](docs/SETUP_GUIDE.md).
|
||||
---
|
||||
|
||||
## Database Schema
|
||||
```
|
||||
# db/schema.rb
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_01_22_100000) do
|
||||
create_table "entries", force: :cascade do |t|
|
||||
t.integer "category", null: false # word, phrase, proper_name, title, reference, other
|
||||
|
||||
# Language columns
|
||||
t.string "fi" # Finnish
|
||||
t.string "en" # English
|
||||
t.string "sv" # Swedish
|
||||
t.string "no" # Norwegian
|
||||
t.string "ru" # Russian
|
||||
t.string "de" # German
|
||||
|
||||
t.text "notes"
|
||||
t.boolean "verified", default: false
|
||||
t.integer "created_by_id"
|
||||
t.integer "updated_by_id"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
|
||||
t.index ["category"], name: "index_entries_on_category"
|
||||
end
|
||||
|
||||
create_table "suggested_meanings", force: :cascade do |t|
|
||||
t.integer "entry_id", null: false
|
||||
t.string "language_code", null: false
|
||||
t.string "alternative_translation", null: false
|
||||
t.text "context"
|
||||
t.text "reasoning"
|
||||
t.string "source"
|
||||
t.string "region"
|
||||
t.integer "status", default: 0 # pending, accepted, rejected
|
||||
t.integer "submitted_by_id"
|
||||
t.integer "reviewed_by_id"
|
||||
t.datetime "reviewed_at"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
|
||||
t.index ["entry_id"], name: "index_suggested_meanings_on_entry_id"
|
||||
t.index ["language_code"], name: "index_suggested_meanings_on_language_code"
|
||||
t.index ["status"], name: "index_suggested_meanings_on_status"
|
||||
end
|
||||
|
||||
create_table "comments", force: :cascade do |t|
|
||||
t.integer "user_id", null: false
|
||||
t.string "commentable_type", null: false
|
||||
t.integer "commentable_id", null: false
|
||||
t.text "body", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
|
||||
t.index ["commentable_type", "commentable_id"], name: "index_comments_on_commentable"
|
||||
end
|
||||
|
||||
create_table "users", force: :cascade do |t|
|
||||
t.string "email", null: false
|
||||
t.string "password_digest", null: false
|
||||
t.string "name"
|
||||
t.integer "role", default: 0 # contributor, reviewer, admin
|
||||
t.string "primary_language"
|
||||
t.string "invitation_token"
|
||||
t.datetime "invitation_sent_at"
|
||||
t.datetime "invitation_accepted_at"
|
||||
t.integer "invited_by_id"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
|
||||
t.index ["email"], name: "index_users_on_email", unique: true
|
||||
t.index ["invitation_token"], name: "index_users_on_invitation_token", unique: true
|
||||
end
|
||||
|
||||
create_table "entry_versions", force: :cascade do |t|
|
||||
t.integer "entry_id", null: false
|
||||
t.integer "user_id", null: false
|
||||
t.json "changes_made", null: false
|
||||
t.string "change_type" # create, update, verify
|
||||
t.datetime "created_at", null: false
|
||||
|
||||
t.index ["entry_id"], name: "index_entry_versions_on_entry_id"
|
||||
end
|
||||
|
||||
create_table "supported_languages", force: :cascade do |t|
|
||||
t.string "code", null: false
|
||||
t.string "name", null: false
|
||||
t.string "native_name", null: false
|
||||
t.integer "sort_order", default: 0
|
||||
t.boolean "active", default: true
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
|
||||
t.index ["code"], name: "index_supported_languages_on_code", unique: true
|
||||
end
|
||||
|
||||
add_foreign_key "suggested_meanings", "entries"
|
||||
add_foreign_key "suggested_meanings", "supported_languages", column: "language_code", primary_key: "code"
|
||||
add_foreign_key "suggested_meanings", "users", column: "submitted_by_id"
|
||||
add_foreign_key "suggested_meanings", "users", column: "reviewed_by_id"
|
||||
add_foreign_key "comments", "users"
|
||||
add_foreign_key "entries", "users", column: "created_by_id"
|
||||
add_foreign_key "entries", "users", column: "updated_by_id"
|
||||
add_foreign_key "entry_versions", "entries"
|
||||
add_foreign_key "entry_versions", "users"
|
||||
end
|
||||
```
|
||||
see `db/structure.sql`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# Sanasto Wiki TODO List
|
||||
|
||||
This document outlines planned improvements, bug fixes, and new features for the Sanasto Wiki application.
|
||||
|
||||
---
|
||||
|
||||
## High Priority
|
||||
|
||||
### Bugs
|
||||
|
||||
- [x] **Search input loses focus on filter change**: This issue has been resolved. The search input now retains focus when filters are applied.
|
||||
- [x] **Mismatched `enum` syntax in models**: This issue has been resolved by correcting the `enum` declarations in `SuggestedMeaning.rb` and `User.rb` to use the updated Rails 8 syntax. All tests now pass.
|
||||
- [ ] **[BUG] Mobile browser access is blocked by `:modern` browser requirement in `ApplicationController`**: This issue has been resolved by removing the `allow_browser versions: :modern` line from `ApplicationController`.
|
||||
|
||||
### Improvements
|
||||
|
||||
- [x] **Replace hardcoded `LANGUAGE_COLUMNS` with dynamic query**: The `Entry` model now dynamically fetches language codes via `SupportedLanguage.valid_codes` and caches them, removing the hardcoded array. This task is completed.
|
||||
|
||||
---
|
||||
|
||||
## Medium Priority
|
||||
|
||||
### New Features
|
||||
|
||||
- [ ] **Add user authentication:** The application currently lacks user authentication, which is a critical security vulnerability. Implementing a robust authentication system will protect sensitive data and ensure only authorized users can make changes.
|
||||
- [ ] **Implement user roles and permissions:** The `README.md` defines user roles (contributor, reviewer, admin), but the application does not yet enforce these roles. Implementing a permissions system will ensure that users can only perform actions appropriate for their role.
|
||||
- [ ] **Add create, edit, update, and destroy actions to `EntriesController`:** The `EntriesController` currently lacks the full set of CRUD actions needed for managing entries.
|
||||
- [ ] **Add views for creating and editing entries:** Corresponding views for entry creation and editing are missing.
|
||||
- [ ] **Add pages for user profiles, admin dashboard, and suggested meanings queue:** Essential UI components for user management and content review are absent.
|
||||
|
||||
### Discussion
|
||||
|
||||
- [x] **Add comments to entries**: Users can now add comments to entries.
|
||||
- [x] **Submit alternative translations as suggested meanings**: This is part of the comments and discussion feature, and the infrastructure for this is in place. Need to verify that the suggested meaning model is used to actually submit alternative translations.
|
||||
- [x] **Participate in translation discussions**: The comments section provides the foundation for this. Additional features might be needed for a full discussion.
|
||||
- [ ] **Plan for user profile based notification exception**: Implement logic to allow users to opt out of notifications for specific language changes or comments on their profile.
|
||||
|
||||
### Refactoring
|
||||
|
||||
- [x] **Improve fixture quality**: The test fixtures have been refactored to resolve conflicts and foreign key violations, ensuring tests pass reliably. This task is completed.
|
||||
|
||||
---
|
||||
|
||||
## Low Priority
|
||||
|
||||
### New Features
|
||||
|
||||
- [x] **Add a download button for entries**: This feature has been implemented in the `EntriesController#download` action and is accessible from the UI. This task is completed.
|
||||
|
||||
### Improvements
|
||||
|
||||
- [ ] **Enhance UI/UX:** While functional, the user interface could be improved to be more intuitive and visually appealing. A design review and subsequent enhancements would improve the overall user experience.
|
||||
- [ ] **Add tests for controllers and views:** The current test suite only covers the models. To ensure the reliability of the application, tests for the controllers and views should also be added.
|
||||
@@ -22,8 +22,7 @@ class Admin::DashboardController < Admin::BaseController
|
||||
.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|
|
||||
@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
|
||||
|
||||
@@ -20,8 +20,8 @@ class Admin::InvitationsController < Admin::BaseController
|
||||
@invitation.password = SecureRandom.urlsafe_base64(16)
|
||||
|
||||
if @invitation.save
|
||||
# TODO: Send invitation email
|
||||
# InvitationMailer.invite(@invitation).deliver_later
|
||||
# Send invitation email
|
||||
InvitationMailer.invite(@invitation).deliver_later
|
||||
|
||||
redirect_to admin_invitations_path, notice: "Invitation sent to #{@invitation.email}"
|
||||
else
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
class ApplicationController < ActionController::Base
|
||||
|
||||
# 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?
|
||||
helper_method :supported_languages, :current_user, :logged_in?, :admin?, :reviewer_or_admin?,
|
||||
:contributor_or_above?, :setup_completed?
|
||||
|
||||
private
|
||||
|
||||
def supported_languages
|
||||
@supported_languages ||= SupportedLanguage.where(active: true).order(:sort_order, :name)
|
||||
end
|
||||
|
||||
def current_user
|
||||
@current_user ||= User.find_by(id: session[:user_id]) if session[:user_id]
|
||||
end
|
||||
@@ -57,6 +61,6 @@ class ApplicationController < ActionController::Base
|
||||
end
|
||||
|
||||
def setup_completed?
|
||||
File.exist?(Rails.root.join(".installed"))
|
||||
SetupState.installed?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
class CommentsController < ApplicationController
|
||||
before_action :require_login
|
||||
before_action :set_commentable
|
||||
|
||||
def create
|
||||
@comment = @commentable.comments.build(comment_params)
|
||||
@comment.user = current_user
|
||||
|
||||
if @comment.save
|
||||
respond_to do |format|
|
||||
format.turbo_stream
|
||||
format.html { redirect_to @commentable }
|
||||
end
|
||||
else
|
||||
# Handle validation errors
|
||||
redirect_to @commentable, alert: "Comment could not be created: #{@comment.errors.full_messages.to_sentence}"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_commentable
|
||||
if params[:entry_id]
|
||||
@commentable = Entry.find(params[:entry_id])
|
||||
end
|
||||
end
|
||||
|
||||
def comment_params
|
||||
params.require(:comment).permit(:body, :language_code)
|
||||
end
|
||||
end
|
||||
@@ -1,8 +1,7 @@
|
||||
class EntriesController < ApplicationController
|
||||
before_action :set_entry, only: [ :show ]
|
||||
before_action :set_entry, only: [ :show, :edit, :update ]
|
||||
|
||||
def index
|
||||
@supported_languages = SupportedLanguage.where(active: true).order(:sort_order, :name)
|
||||
@language_code = params[:language].presence
|
||||
@category = params[:category].presence
|
||||
@query = params[:q].to_s.strip
|
||||
@@ -24,21 +23,21 @@ class EntriesController < ApplicationController
|
||||
@entry_count = Entry.count
|
||||
@verified_count = Entry.where(verified: true).count
|
||||
@needs_review_count = @entry_count - @verified_count
|
||||
@complete_entries_count = @supported_languages.reduce(Entry.all) do |scope, language|
|
||||
@complete_entries_count = supported_languages.reduce(Entry.all) do |scope, language|
|
||||
scope.where.not(language.code => [ nil, "" ])
|
||||
end.count
|
||||
@missing_entries_count = @entry_count - @complete_entries_count
|
||||
@language_completion = @supported_languages.index_with do |language|
|
||||
@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
|
||||
|
||||
if @language_code.present?
|
||||
primary_language, other_languages = @supported_languages.partition { |language| language.code == @language_code }
|
||||
primary_language, other_languages = supported_languages.partition { |language| language.code == @language_code }
|
||||
@display_languages = primary_language + other_languages
|
||||
else
|
||||
@display_languages = @supported_languages
|
||||
@display_languages = supported_languages
|
||||
end
|
||||
|
||||
respond_to do |format|
|
||||
@@ -48,7 +47,17 @@ class EntriesController < ApplicationController
|
||||
end
|
||||
|
||||
def show
|
||||
@supported_languages = SupportedLanguage.where(active: true).order(:sort_order, :name)
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
if @entry.update(entry_params)
|
||||
redirect_to entry_path(@entry), notice: "Entry updated."
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def download
|
||||
@@ -66,4 +75,8 @@ class EntriesController < ApplicationController
|
||||
def set_entry
|
||||
@entry = Entry.find(params[:id])
|
||||
end
|
||||
|
||||
def entry_params
|
||||
params.require(:entry).permit(:category)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
class InvitationsController < ApplicationController
|
||||
def show
|
||||
@user = User.find_by_valid_invitation_token(params[:token])
|
||||
|
||||
if @user.nil?
|
||||
redirect_to root_path, alert: "Invalid or expired invitation link."
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
@user = User.find_by_valid_invitation_token(params[:token])
|
||||
|
||||
if @user.nil?
|
||||
redirect_to root_path, alert: "Invalid or expired invitation link."
|
||||
return
|
||||
end
|
||||
|
||||
if @user.update(invitation_params)
|
||||
@user.update(
|
||||
invitation_accepted_at: Time.current,
|
||||
invitation_token: nil
|
||||
)
|
||||
|
||||
session[:user_id] = @user.id
|
||||
redirect_to admin? ? admin_root_path : root_path, notice: "Welcome to Sanasto Wiki, #{@user.name}!"
|
||||
else
|
||||
render :show, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def invitation_params
|
||||
params.require(:user).permit(:password, :password_confirmation)
|
||||
end
|
||||
end
|
||||
@@ -11,7 +11,7 @@ class SetupController < ApplicationController
|
||||
@user.invitation_accepted_at = Time.current
|
||||
|
||||
if @user.save
|
||||
create_installed_marker
|
||||
SetupState.mark_installed!
|
||||
session[:user_id] = @user.id
|
||||
redirect_to admin_root_path, notice: "Setup complete! Welcome to Sanasto Wiki."
|
||||
else
|
||||
@@ -28,15 +28,7 @@ class SetupController < ApplicationController
|
||||
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)
|
||||
SetupState.installed?
|
||||
end
|
||||
|
||||
def user_params
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
module ApplicationHelper
|
||||
def language_name(code)
|
||||
supported_languages.find { |l| l.code == code }&.name
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["modal", "button"]
|
||||
|
||||
connect() {
|
||||
this.modalTarget.classList.add("hidden")
|
||||
this.buttonTarget.classList.remove("hidden")
|
||||
}
|
||||
|
||||
open(event) {
|
||||
event.preventDefault()
|
||||
this.modalTarget.classList.remove("hidden")
|
||||
}
|
||||
|
||||
close(event) {
|
||||
if (event.target === this.modalTarget) {
|
||||
this.modalTarget.classList.add("hidden")
|
||||
}
|
||||
}
|
||||
|
||||
closeWithButton() {
|
||||
this.modalTarget.classList.add("hidden")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
class InvitationMailer < ApplicationMailer
|
||||
def invite(user)
|
||||
@user = user
|
||||
@invitation_url = invitation_url(@user.invitation_token)
|
||||
@expires_at = @user.invitation_sent_at + User::INVITATION_TOKEN_EXPIRY
|
||||
|
||||
mail(
|
||||
to: @user.email,
|
||||
subject: "You've been invited to join Sanasto Wiki"
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,23 @@
|
||||
class Comment < ApplicationRecord
|
||||
belongs_to :user
|
||||
belongs_to :commentable, polymorphic: true
|
||||
belongs_to :language,
|
||||
class_name: "SupportedLanguage",
|
||||
foreign_key: :language_code,
|
||||
primary_key: :code,
|
||||
optional: true
|
||||
|
||||
validates :body, presence: true
|
||||
|
||||
after_create_commit :notify_users
|
||||
|
||||
private
|
||||
|
||||
def notify_users
|
||||
return if language_code.blank?
|
||||
|
||||
# Placeholder for notification logic once we decide delivery channels.
|
||||
users_to_notify = User.where(primary_language: language_code).where.not(id: user_id)
|
||||
# puts "Notifying users: #{users_to_notify.pluck(:email).join(", ")}"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,7 +4,6 @@ class Entry < ApplicationRecord
|
||||
|
||||
has_many :suggested_meanings, dependent: :destroy
|
||||
has_many :comments, as: :commentable, dependent: :destroy
|
||||
has_many :entry_versions, dependent: :destroy
|
||||
|
||||
enum :category, %i[word phrase proper_name title reference other]
|
||||
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
class EntryVersion < ApplicationRecord
|
||||
belongs_to :entry
|
||||
belongs_to :user
|
||||
|
||||
validates :changes_made, presence: true
|
||||
end
|
||||
@@ -0,0 +1,16 @@
|
||||
class SetupState < ApplicationRecord
|
||||
def self.installed?
|
||||
first&.installed? || false
|
||||
end
|
||||
|
||||
def self.mark_installed!
|
||||
record = first_or_initialize
|
||||
record.installed = true
|
||||
record.installed_at ||= Time.current
|
||||
record.save!
|
||||
end
|
||||
|
||||
def self.reset!
|
||||
delete_all
|
||||
end
|
||||
end
|
||||
+19
-1
@@ -14,11 +14,29 @@ class User < ApplicationRecord
|
||||
class_name: "SuggestedMeaning",
|
||||
foreign_key: :reviewed_by_id,
|
||||
dependent: :nullify
|
||||
has_many :entry_versions, dependent: :nullify
|
||||
has_many :comments, dependent: :nullify
|
||||
|
||||
enum :role, %i[contributor reviewer admin]
|
||||
|
||||
validates :email, presence: true, uniqueness: true
|
||||
validates :password, length: { minimum: 12 }, if: -> { password.present? }
|
||||
|
||||
# Invitation token expires after 14 days
|
||||
INVITATION_TOKEN_EXPIRY = 14.days
|
||||
|
||||
def invitation_expired?
|
||||
return false if invitation_sent_at.nil?
|
||||
invitation_sent_at < INVITATION_TOKEN_EXPIRY.ago
|
||||
end
|
||||
|
||||
def invitation_pending?
|
||||
invitation_token.present? && invitation_accepted_at.nil? && !invitation_expired?
|
||||
end
|
||||
|
||||
def self.find_by_valid_invitation_token(token)
|
||||
where(invitation_token: token)
|
||||
.where(invitation_accepted_at: nil)
|
||||
.where("invitation_sent_at > ?", INVITATION_TOKEN_EXPIRY.ago)
|
||||
.first
|
||||
end
|
||||
end
|
||||
|
||||
@@ -115,7 +115,7 @@
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900">Language Completion</h3>
|
||||
<div class="mt-5">
|
||||
<div class="space-y-4">
|
||||
<% @supported_languages.each do |language| %>
|
||||
<% supported_languages.each do |language| %>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-700"><%= language.native_name %></span>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<%= turbo_stream.append "comments-#{@comment.language_code.presence || 'all'}" do %>
|
||||
<%= render "entries/comment", comment: @comment %>
|
||||
<% end %>
|
||||
|
||||
<% if @comment.language_code.present? %>
|
||||
<%= turbo_stream.replace "comment-details-#{@comment.language_code}" do %>
|
||||
<%= render "entries/language_comment_details", entry: @commentable, language_code: @comment.language_code %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%= turbo_stream.replace "comment_tabs" do %>
|
||||
<%= render "entries/comment_tabs", entry: @commentable %>
|
||||
<% end %>
|
||||
@@ -0,0 +1,24 @@
|
||||
<div class="flex items-start space-x-4">
|
||||
<div class="flex-shrink-0">
|
||||
<%# Add user avatars here if you have them %>
|
||||
<div class="h-10 w-10 rounded-full bg-slate-200 flex items-center justify-center text-slate-600 font-bold">
|
||||
<%= comment.user&.name&.first || 'A' %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="bg-slate-100 rounded-lg p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm text-slate-900">
|
||||
<span class="font-semibold "><%= comment.user&.name || "Anonymous" %></span>
|
||||
<% unless comment.language_code.blank? %>
|
||||
<span class="italic">on the <%= language_name(comment.language_code) %> translation</span>
|
||||
<% end -%>
|
||||
</p>
|
||||
<p class="text-xs text-slate-500">
|
||||
<%= comment.created_at ? "#{time_ago_in_words(comment.created_at)} ago" : "just now" %>
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-sm text-slate-800 mt-1"><%= comment.body %></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,12 @@
|
||||
<%= form_with(model: [entry, Comment.new(commentable: entry)],
|
||||
data: { turbo_stream: true },
|
||||
html: { class: "space-y-4" }) do |form| %>
|
||||
<%= form.hidden_field :language_code, value: (local_assigns[:language_code].presence || nil) %>
|
||||
<div>
|
||||
<%= form.label :body, "Comment", class: "sr-only" %>
|
||||
<%= form.text_area :body, rows: 4, class: "block w-full border-slate-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm", placeholder: "Add your comment..." %>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<%= form.submit "Submit", class: "bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition cursor-pointer" %>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -0,0 +1,30 @@
|
||||
<div id="comment_tabs" class="border-b border-slate-200">
|
||||
<nav class="-mb-px flex space-x-6" aria-label="Tabs">
|
||||
<% grouped_comments = entry.comments.group_by(&:language) %>
|
||||
<% language_groups = supported_languages.map { |language| [language, grouped_comments[language] || []] } %>
|
||||
<% language_groups.unshift([:all, entry.comments]) %>
|
||||
<% language_groups.each do |language, comments| %>
|
||||
<% language_label = language == :all ? "All languages" : language&.name %>
|
||||
<% language_code = language == :all ? "all" : language&.code %>
|
||||
<a href="#"
|
||||
class="comment-tab border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm"
|
||||
data-lang="<%= language_code %>">
|
||||
<%= language_label %> <span class="bg-slate-100 text-slate-600 ml-2 py-0.5 px-2.5 rounded-full text-xs font-medium"><%= comments.count %></span>
|
||||
</a>
|
||||
<% end %>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<% if entry.comments.empty? %>
|
||||
<p class="text-slate-500">No comments yet. Be the first to add one!</p>
|
||||
<% end %>
|
||||
<% language_groups.each do |language, comments| %>
|
||||
<% language_code = language == :all ? "all" : language&.code %>
|
||||
<div id="comments-<%= language_code %>" class="comment-group space-y-4 hidden">
|
||||
<% comments.each do |comment| %>
|
||||
<%= render "entries/comment", comment: comment %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -0,0 +1,57 @@
|
||||
<% if current_user %>
|
||||
<div class="mt-8" data-controller="comments">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-bold text-slate-900">Discussion</h3>
|
||||
<button id="add_comment_button" data-action="click->comments#open" data-comments-target="button" class="bg-indigo-600 text-white px-4 py-2 rounded-full shadow hover:bg-indigo-700 transition text-sm font-semibold">
|
||||
Add Comment
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<%= render "entries/comment_tabs", entry: entry %>
|
||||
|
||||
<div id="comment_form_modal" data-comments-target="modal" data-action="click->comments#close" class="hidden fixed inset-0 bg-slate-900 bg-opacity-50 z-40 flex items-center justify-center">
|
||||
<div class="bg-white rounded-lg shadow-xl p-6 w-full max-w-lg">
|
||||
<div class="flex justify-between items-center">
|
||||
<h4 class="text-lg font-bold">Add a Comment</h4>
|
||||
<button id="close_comment_form" data-action="click->comments#closeWithButton" class="text-slate-500 hover:text-slate-700 text-2xl leading-none">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<%= render "entries/comment_form", entry: entry %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function activateCommentTab(targetLanguageCode) {
|
||||
const commentGroups = document.querySelectorAll(".comment-group");
|
||||
commentGroups.forEach((group) => {
|
||||
const isTarget = group.id === `comments-${targetLanguageCode}`;
|
||||
group.classList.toggle("hidden", !isTarget);
|
||||
});
|
||||
|
||||
const tabs = document.querySelectorAll(".comment-tab");
|
||||
tabs.forEach((tab) => {
|
||||
const isActive = tab.dataset.lang === targetLanguageCode;
|
||||
tab.classList.toggle("text-slate-900", isActive);
|
||||
tab.classList.toggle("border-indigo-600", isActive);
|
||||
tab.classList.toggle("text-slate-500", !isActive);
|
||||
tab.classList.toggle("border-transparent", !isActive);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
const tab = event.target.closest(".comment-tab");
|
||||
if (!tab) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
activateCommentTab(tab.dataset.lang);
|
||||
});
|
||||
|
||||
document.addEventListener("turbo:load", () => {
|
||||
activateCommentTab("all");
|
||||
});
|
||||
</script>
|
||||
<% end %>
|
||||
@@ -1,43 +1,43 @@
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<% base_params = { q: @query, category: @category.presence, language: @language_code.presence, starts_with: @starts_with.presence }.compact %>
|
||||
<% all_category_params = base_params.except(:category) %>
|
||||
<%= link_to "All",
|
||||
entries_path(all_category_params),
|
||||
class: "px-4 py-1.5 rounded-full #{@category.blank? ? 'bg-indigo-100 text-indigo-700' : 'bg-white border border-slate-200 text-slate-600 hover:border-indigo-300'} text-xs font-bold uppercase tracking-wider",
|
||||
class: "px-3 py-1 rounded-full #{@category.blank? ? 'bg-indigo-100 text-indigo-700' : 'bg-white border border-slate-200 text-slate-600 hover:border-indigo-300'} text-xs font-bold uppercase tracking-wider",
|
||||
data: { turbo_stream: true } %>
|
||||
<% Entry.categories.keys.each do |category_name| %>
|
||||
<%= link_to category_name.tr('_', ' ').capitalize,
|
||||
entries_path(base_params.merge(category: category_name)),
|
||||
class: "px-4 py-1.5 rounded-full #{@category == category_name ? 'bg-indigo-100 text-indigo-700' : 'bg-white border border-slate-200 text-slate-600 hover:border-indigo-300'} text-xs font-bold uppercase tracking-wider",
|
||||
class: "px-3 py-1 rounded-full #{@category == category_name ? 'bg-indigo-100 text-indigo-700' : 'bg-white border border-slate-200 text-slate-600 hover:border-indigo-300'} text-xs font-bold uppercase tracking-wider",
|
||||
data: { turbo_stream: true } %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 mt-2 mb-2">
|
||||
<div class="flex flex-wrap gap-1 mt-2 mb-2">
|
||||
<% all_language_params = base_params.except(:language, :starts_with, :page) %>
|
||||
<%= link_to "All Languages",
|
||||
entries_path(all_language_params),
|
||||
class: "px-3 py-1.5 rounded-full #{@language_code.blank? ? 'bg-slate-900 text-white' : 'bg-white border border-slate-200 text-slate-600 hover:border-slate-300'} text-xs font-semibold uppercase tracking-wider",
|
||||
class: "px-3 py-1 rounded-full #{@language_code.blank? ? 'bg-slate-900 text-white' : 'bg-white border border-slate-200 text-slate-600 hover:border-slate-300'} text-xs font-semibold uppercase tracking-wider",
|
||||
data: { turbo_stream: true } %>
|
||||
<% @supported_languages.each do |language| %>
|
||||
<% supported_languages.each do |language| %>
|
||||
<%= link_to "#{language.name} (#{language.code.upcase})",
|
||||
entries_path(all_language_params.merge(language: language.code)),
|
||||
class: "px-3 py-1.5 rounded-full #{@language_code == language.code ? 'bg-slate-900 text-white' : 'bg-white border border-slate-200 text-slate-600 hover:border-slate-300'} text-xs font-semibold uppercase tracking-wider",
|
||||
class: "px-3 py-1 rounded-full #{@language_code == language.code ? 'bg-slate-900 text-white' : 'bg-white border border-slate-200 text-slate-600 hover:border-slate-300'} text-xs font-semibold uppercase tracking-wider",
|
||||
data: { turbo_stream: true } %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if @language_code.present? %>
|
||||
<div class="flex flex-wrap gap-2 text-xs mb-2">
|
||||
<div class="flex flex-wrap gap-1 text-xs mb-2">
|
||||
<% alphabet_params = base_params.merge(language: @language_code).except(:starts_with, :page) %>
|
||||
<%= link_to "All",
|
||||
entries_path(alphabet_params),
|
||||
class: "px-2.5 py-1 rounded-md #{@starts_with.blank? ? 'bg-indigo-600 text-white' : 'bg-white border border-slate-200 text-slate-600 hover:border-indigo-300'}",
|
||||
class: "px-2 py-1 rounded-md #{@starts_with.blank? ? 'bg-indigo-600 text-white' : 'bg-white border border-slate-200 text-slate-600 hover:border-indigo-300'}",
|
||||
data: { turbo_stream: true } %>
|
||||
<% alphabet_letters(@language_code).each do |letter| %>
|
||||
<%= link_to letter,
|
||||
entries_path(alphabet_params.merge(starts_with: letter)),
|
||||
class: "px-2.5 py-1 rounded-md #{@starts_with == letter ? 'bg-indigo-600 text-white' : 'bg-white border border-slate-200 text-slate-600 hover:border-indigo-300'}",
|
||||
class: "px-2 py-1 rounded-md #{@starts_with == letter ? 'bg-indigo-600 text-white' : 'bg-white border border-slate-200 text-slate-600 hover:border-indigo-300'}",
|
||||
data: { turbo_stream: true } %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<details class="text-xs">
|
||||
<summary class="inline-flex items-center gap-1 text-indigo-600 font-semibold cursor-pointer">
|
||||
Add comment
|
||||
</summary>
|
||||
<div class="mt-3">
|
||||
<%= render "entries/comment_form", entry: entry, language_code: language_code %>
|
||||
</div>
|
||||
</details>
|
||||
@@ -0,0 +1,70 @@
|
||||
<% content_for :title, "Edit Entry" %>
|
||||
|
||||
<nav class="sticky top-0 z-50 bg-white border-b border-slate-200">
|
||||
<div class="max-w-5xl mx-auto px-4 h-16 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xl font-bold tracking-tight text-indigo-600">Sanasto</span>
|
||||
<span class="text-xl font-light text-slate-400">Wiki</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<%= link_to "Browse", entries_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600" %>
|
||||
<%= link_to "Download XLSX", download_entries_path(format: :xlsx), class: "text-sm font-semibold text-indigo-700 px-3 py-2 rounded-lg border border-indigo-200 bg-indigo-50 hover:bg-indigo-100 transition" %>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="max-w-5xl mx-auto px-4 py-8 space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<%= link_to "← Back to entry", entry_path(@entry), class: "text-sm text-slate-500 hover:text-indigo-600" %>
|
||||
<%= link_to "Back to search", entries_path, class: "text-sm text-slate-500 hover:text-indigo-600" %>
|
||||
</div>
|
||||
|
||||
<div class="bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-slate-100 bg-slate-50/50 flex justify-between items-center">
|
||||
<span class="text-[10px] font-black uppercase tracking-widest text-slate-400">Edit Category</span>
|
||||
<% if @entry.verified? %>
|
||||
<div class="flex items-center gap-1.5 text-emerald-600">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>
|
||||
<span class="text-xs font-bold">Verified</span>
|
||||
</div>
|
||||
<% else %>
|
||||
<span class="text-xs font-semibold text-amber-600">Unverified</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="p-6 space-y-6">
|
||||
<%= form_with model: @entry, class: "space-y-4" do |form| %>
|
||||
<div>
|
||||
<%= form.label :category, "Category", class: "block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2" %>
|
||||
<%= form.select :category,
|
||||
Entry.categories.keys.map { |key| [key.tr("_", " ").capitalize, key] },
|
||||
{},
|
||||
class: "block w-full border-slate-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" %>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<%= form.submit "Save Category", class: "bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-y-6 gap-x-12">
|
||||
<% supported_languages.each do |language| %>
|
||||
<% translation = entry_translation_for(@entry, language.code) %>
|
||||
<% next if translation.blank? %>
|
||||
<div class="space-y-2">
|
||||
<div class="grid grid-cols-2">
|
||||
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-tight"><%= "#{language.name} (#{language.code.upcase})" %></span>
|
||||
</div>
|
||||
<p class="text-2xl font-semibold text-slate-800"><%= translation %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if @entry.notes.present? %>
|
||||
<div class="mt-6 pt-5 border-t border-slate-100">
|
||||
<h4 class="text-xs font-bold text-slate-400 uppercase mb-2">Context & Notes</h4>
|
||||
<p class="text-sm text-slate-600 italic"><%= @entry.notes %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@@ -21,6 +21,7 @@
|
||||
<div class="bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-slate-100 bg-slate-50/50 flex justify-between items-center">
|
||||
<span class="text-[10px] font-black uppercase tracking-widest text-slate-400"><%= format_entry_category(@entry) %></span>
|
||||
<%= link_to "Edit", edit_entry_path(@entry) if admin? %>
|
||||
<% if @entry.verified? %>
|
||||
<div class="flex items-center gap-1.5 text-emerald-600">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>
|
||||
@@ -33,12 +34,19 @@
|
||||
|
||||
<div class="p-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-y-6 gap-x-12">
|
||||
<% @supported_languages.each do |language| %>
|
||||
<% supported_languages.each do |language| %>
|
||||
<% translation = entry_translation_for(@entry, language.code) %>
|
||||
<% next if translation.blank? %>
|
||||
<div class="space-y-1">
|
||||
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-tight"><%= "#{language.name} (#{language.code.upcase})" %></span>
|
||||
<div class="space-y-2">
|
||||
<div class="grid grid-cols-2">
|
||||
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-tight"><%= "#{language.name} (#{language.code.upcase})" %></span>
|
||||
</div>
|
||||
<p class="text-2xl font-semibold text-slate-800"><%= translation %></p>
|
||||
<% if current_user %>
|
||||
<div id="comment-details-<%= language.code %>">
|
||||
<%= render "entries/language_comment_details", entry: @entry, language_code: language.code %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -51,4 +59,6 @@
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= render "entries/comments_section", entry: @entry %>
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta content='text/html; charset=UTF-8' http-equiv='Content-Type' />
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #334155;
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.header {
|
||||
background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);
|
||||
color: white;
|
||||
padding: 30px;
|
||||
border-radius: 8px 8px 0 0;
|
||||
text-align: center;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.header p {
|
||||
margin: 8px 0 0 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.content {
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-top: none;
|
||||
padding: 30px;
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
.greeting {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
color: #1e293b;
|
||||
}
|
||||
.info-box {
|
||||
background: #f8fafc;
|
||||
border-left: 4px solid #6366f1;
|
||||
padding: 16px;
|
||||
margin: 20px 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.info-box strong {
|
||||
color: #1e293b;
|
||||
}
|
||||
.button {
|
||||
display: inline-block;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);
|
||||
color: white;
|
||||
padding: 14px 32px;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
margin: 24px 0;
|
||||
text-align: center;
|
||||
}
|
||||
.button:hover {
|
||||
background: linear-gradient(135deg, #4f46e5 0%, #4338ca 100%);
|
||||
}
|
||||
.footer {
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
}
|
||||
.expiry-notice {
|
||||
background: #fef3c7;
|
||||
border-left: 4px solid #f59e0b;
|
||||
padding: 12px;
|
||||
margin: 16px 0;
|
||||
font-size: 14px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Sanasto Wiki</h1>
|
||||
<p>Kristillisyyden sanasto</p>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<p class="greeting">Hello <%= @user.name %>,</p>
|
||||
|
||||
<p>
|
||||
The <strong>Sanasto Wiki</strong> let you search and compare, or download, translations across languages used all over the living Christianity.
|
||||
</p>
|
||||
|
||||
<p>With a login account, you can contribute to this work.</p>
|
||||
|
||||
<div class="info-box">
|
||||
<p style="margin: 0;"><strong>Your Account Details:</strong></p>
|
||||
<p style="margin: 8px 0 0 0;">
|
||||
Email: <%= @user.email %><br>
|
||||
Role: <%= @user.role.titleize %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
To accept this invitation and set your password, click the button below:
|
||||
</p>
|
||||
|
||||
<div style="text-align: center;">
|
||||
<%= link_to "Accept Invitation", @invitation_url, class: "button" %>
|
||||
</div>
|
||||
|
||||
<div class="expiry-notice">
|
||||
This invitation will expire on <strong><%= @expires_at.strftime("%B %d, %Y at %I:%M %p %Z") %></strong>.
|
||||
</div>
|
||||
|
||||
<p>
|
||||
You can also copy and paste this link into your browser:
|
||||
</p>
|
||||
<p style="word-break: break-all; color: #6366f1; font-size: 14px;">
|
||||
<%= @invitation_url %>
|
||||
</p>
|
||||
|
||||
<div class="footer">
|
||||
<p>
|
||||
If you weren't expecting this invitation, you can safely ignore this email.
|
||||
</p>
|
||||
<p style="margin-top: 12px;">
|
||||
Questions? Reply to this email.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,26 @@
|
||||
========================================
|
||||
SANASTO WIKI - INVITATION
|
||||
========================================
|
||||
|
||||
Hello <%= @user.name %>,
|
||||
|
||||
The Sanasto Wiki let you search and compare, or download, translations across languages used all over the living Christianity.
|
||||
|
||||
With a login account, you can contribute to this work.
|
||||
|
||||
YOUR ACCOUNT DETAILS
|
||||
--------------------
|
||||
Email: <%= @user.email %>
|
||||
Role: <%= @user.role.titleize %>
|
||||
|
||||
TO ACCEPT THIS INVITATION
|
||||
--------------------------
|
||||
Please visit the following link to set your password and complete your registration:
|
||||
|
||||
<%= @invitation_url %>
|
||||
|
||||
This invitation will expire on <%= @expires_at.strftime("%B %d, %Y at %I:%M %p %Z") %>.
|
||||
|
||||
If you weren't expecting this invitation, you can safely ignore this email.
|
||||
|
||||
Questions? Reply to this email.
|
||||
@@ -0,0 +1,75 @@
|
||||
<% content_for :title, "Accept Invitation" %>
|
||||
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<header class="bg-white border-b border-slate-200">
|
||||
<div class="max-w-7xl mx-auto px-4">
|
||||
<div class="h-16 flex items-center">
|
||||
<%= link_to root_path, class: "flex items-center gap-2" do %>
|
||||
<span class="text-xl font-bold tracking-tight text-indigo-600">Sanasto</span>
|
||||
<span class="text-xl font-light text-slate-400">Wiki</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="flex-1 flex items-center justify-center px-4 py-12 bg-slate-50">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-8">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-2xl font-bold text-slate-900 mb-2">Accept Invitation</h1>
|
||||
<p class="text-sm text-slate-600">
|
||||
You've been invited to join Sanasto Wiki as <%= @user.name %> (<%= @user.email %>)
|
||||
</p>
|
||||
<p class="text-sm text-slate-600 mt-1">
|
||||
Role: <span class="font-medium text-indigo-600"><%= @user.role.titleize %></span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<% if @user.errors.any? %>
|
||||
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6" role="alert">
|
||||
<h3 class="font-medium mb-2"><%= pluralize(@user.errors.count, "error") %> prevented acceptance:</h3>
|
||||
<ul class="list-disc pl-5 space-y-1 text-sm">
|
||||
<% @user.errors.full_messages.each do |message| %>
|
||||
<li><%= message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= form_with model: @user, url: accept_invitation_path(params[:token]), method: :patch, local: true, class: "space-y-5" do |form| %>
|
||||
<div>
|
||||
<%= form.label :password, "Set Your Password", class: "block text-sm font-medium text-slate-700 mb-2" %>
|
||||
<%= form.password_field :password,
|
||||
autofocus: true,
|
||||
required: true,
|
||||
placeholder: "Minimum 12 characters",
|
||||
class: "block w-full px-4 py-3 bg-white border border-slate-200 rounded-lg shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition" %>
|
||||
<p class="mt-1 text-xs text-slate-500">Choose a strong password with at least 12 characters.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :password_confirmation, "Confirm Password", class: "block text-sm font-medium text-slate-700 mb-2" %>
|
||||
<%= form.password_field :password_confirmation,
|
||||
required: true,
|
||||
placeholder: "Re-enter your password",
|
||||
class: "block w-full px-4 py-3 bg-white border border-slate-200 rounded-lg shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition" %>
|
||||
</div>
|
||||
|
||||
<div class="pt-2">
|
||||
<%= form.submit "Accept Invitation & Join",
|
||||
class: "w-full bg-indigo-600 text-white px-4 py-3 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition cursor-pointer" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<%= link_to root_path, class: "text-sm text-slate-600 hover:text-indigo-600 transition inline-flex items-center gap-1" do %>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Back to Wiki
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
+5
-1
@@ -21,6 +21,10 @@ Rails.application.routes.draw do
|
||||
post "login", to: "sessions#create"
|
||||
delete "logout", to: "sessions#destroy", as: :logout
|
||||
|
||||
# Invitation acceptance routes
|
||||
get "invitations/:token", to: "invitations#show", as: :invitation
|
||||
patch "invitations/:token/accept", to: "invitations#update", as: :accept_invitation
|
||||
|
||||
# Admin namespace
|
||||
namespace :admin do
|
||||
root "dashboard#index"
|
||||
@@ -30,13 +34,13 @@ Rails.application.routes.draw do
|
||||
end
|
||||
|
||||
resources :entries do
|
||||
resources :comments, only: [:create]
|
||||
collection do
|
||||
get :download
|
||||
end
|
||||
end
|
||||
resources :suggested_meanings
|
||||
resources :comments, only: [:create, :update, :destroy]
|
||||
resources :entry_versions, only: [:index, :show]
|
||||
resources :supported_languages, only: [:index, :show]
|
||||
resources :users
|
||||
end
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
class CreateSetupStates < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
create_table :setup_states do |t|
|
||||
t.boolean :installed, null: false, default: false
|
||||
t.datetime :installed_at
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,14 @@
|
||||
class DropEntryVersions < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
drop_table :entry_versions do |t|
|
||||
t.text :changes_made
|
||||
t.string :change_type
|
||||
t.datetime :created_at, null: false
|
||||
t.integer :entry_id, null: false
|
||||
t.datetime :updated_at, null: false
|
||||
t.integer :user_id
|
||||
t.index [ :entry_id ], name: "index_entry_versions_on_entry_id"
|
||||
t.index [ :user_id ], name: "index_entry_versions_on_user_id"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddLanguageCodeToComments < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
add_column :comments, :language_code, :string
|
||||
end
|
||||
end
|
||||
+5
-10
@@ -10,21 +10,12 @@ FOREIGN KEY ("updated_by_id")
|
||||
CREATE INDEX "index_entries_on_created_by_id" ON "entries" ("created_by_id") /*application='SanastoWiki'*/;
|
||||
CREATE INDEX "index_entries_on_updated_by_id" ON "entries" ("updated_by_id") /*application='SanastoWiki'*/;
|
||||
CREATE INDEX "index_entries_on_category" ON "entries" ("category") /*application='SanastoWiki'*/;
|
||||
CREATE TABLE IF NOT EXISTS "comments" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "user_id" integer NOT NULL, "commentable_type" varchar NOT NULL, "commentable_id" integer NOT NULL, "body" text NOT NULL, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_03de2dc08c"
|
||||
CREATE TABLE IF NOT EXISTS "comments" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "user_id" integer NOT NULL, "commentable_type" varchar NOT NULL, "commentable_id" integer NOT NULL, "body" text NOT NULL, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, "language_code" varchar /*application='SanastoWiki'*/, CONSTRAINT "fk_rails_03de2dc08c"
|
||||
FOREIGN KEY ("user_id")
|
||||
REFERENCES "users" ("id")
|
||||
);
|
||||
CREATE INDEX "index_comments_on_user_id" ON "comments" ("user_id") /*application='SanastoWiki'*/;
|
||||
CREATE INDEX "index_comments_on_commentable" ON "comments" ("commentable_type", "commentable_id") /*application='SanastoWiki'*/;
|
||||
CREATE TABLE IF NOT EXISTS "entry_versions" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "entry_id" integer NOT NULL, "user_id" integer NOT NULL, "changes_made" json NOT NULL, "change_type" varchar, "created_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_be24c8cfa1"
|
||||
FOREIGN KEY ("entry_id")
|
||||
REFERENCES "entries" ("id")
|
||||
, CONSTRAINT "fk_rails_aaeb10db8b"
|
||||
FOREIGN KEY ("user_id")
|
||||
REFERENCES "users" ("id")
|
||||
);
|
||||
CREATE INDEX "index_entry_versions_on_entry_id" ON "entry_versions" ("entry_id") /*application='SanastoWiki'*/;
|
||||
CREATE INDEX "index_entry_versions_on_user_id" ON "entry_versions" ("user_id") /*application='SanastoWiki'*/;
|
||||
CREATE TABLE IF NOT EXISTS "supported_languages" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "code" varchar NOT NULL, "name" varchar NOT NULL, "native_name" varchar NOT NULL, "sort_order" integer DEFAULT 0 NOT NULL, "active" boolean DEFAULT TRUE NOT NULL, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL);
|
||||
CREATE UNIQUE INDEX "index_supported_languages_on_code" ON "supported_languages" ("code") /*application='SanastoWiki'*/;
|
||||
CREATE TABLE IF NOT EXISTS "users" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "password_digest" varchar NOT NULL, "name" varchar, "role" integer DEFAULT 0 NOT NULL, "primary_language" varchar, "invitation_token" varchar, "invitation_sent_at" datetime(6), "invitation_accepted_at" datetime(6), "invited_by_id" integer, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_ae14a5013f"
|
||||
@@ -88,7 +79,11 @@ BEGIN
|
||||
INSERT INTO entries_fts(entries_fts, rowid, fi, en, sv, no, ru, de, notes)
|
||||
VALUES('delete', old.id, old.fi, old.en, old.sv, old.no, old.ru, old.de, old.notes);
|
||||
END;
|
||||
CREATE TABLE IF NOT EXISTS "setup_states" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "installed" boolean DEFAULT FALSE NOT NULL, "installed_at" datetime(6), "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL);
|
||||
INSERT INTO "schema_migrations" (version) VALUES
|
||||
('20260123130957'),
|
||||
('20260123125325'),
|
||||
('20260122131000'),
|
||||
('20260122130000'),
|
||||
('20260122124151'),
|
||||
('20260122123837'),
|
||||
|
||||
+10
-11
@@ -10,12 +10,12 @@
|
||||
- [ ] Password reset flow
|
||||
- [ ] Rate limiting on login attempts
|
||||
- [ ] Session management (remember me, session timeout)
|
||||
- [ ] **Invitation system**
|
||||
- [x] **Invitation system**
|
||||
- [x] Invitations controller (create, list, cancel)
|
||||
- [x] Invitation token generation
|
||||
- [ ] Registration via invitation link (acceptance flow)
|
||||
- [ ] Token expiry validation (14 days)
|
||||
- [ ] Invitation mailer
|
||||
- [x] Registration via invitation link (acceptance flow)
|
||||
- [x] Token expiry validation (14 days)
|
||||
- [x] Invitation mailer
|
||||
- [ ] **Authorization & roles**
|
||||
- [x] Role-based access control middleware (Admin::BaseController)
|
||||
- [x] Admin permissions enforcement
|
||||
@@ -50,17 +50,11 @@
|
||||
- [ ] **Comment threading** (optional: replies to comments)
|
||||
- [ ] **Comment notifications** for entry contributors
|
||||
|
||||
### History & Audit
|
||||
- [ ] **Entry version tracking** (record all changes in `entry_versions`)
|
||||
- [ ] **View edit history** on entry page
|
||||
- [ ] **Diff view** showing what changed
|
||||
- [ ] **Revert to previous version** (admin/reviewer only)
|
||||
|
||||
## User Management
|
||||
|
||||
- [x] **Setup** adds the first user
|
||||
- [x] **Admin dashboard**
|
||||
- [x] Send invitations interface (email delivery pending mailer implementation)
|
||||
- [x] Send invitations interface (with email delivery)
|
||||
- [x] Manage users (list, edit roles, delete)
|
||||
- [x] System statistics (users, entries, contributions)
|
||||
- [ ] **User profile page**
|
||||
@@ -126,6 +120,11 @@
|
||||
|
||||
## Completed
|
||||
|
||||
- [x] **Invitation system** (complete flow with email, acceptance, and expiry validation)
|
||||
- [x] **Invitation acceptance flow** (users can accept invitations and set passwords)
|
||||
- [x] **Invitation mailer** (HTML and text email templates with styled design)
|
||||
- [x] **Token expiry validation** (14-day expiration for invitation links)
|
||||
- [x] **Controller tests** (40 tests with 160+ assertions for authentication)
|
||||
- [x] **Authentication system** (login/logout with session management)
|
||||
- [x] **Admin layout design** updated to match entries page style
|
||||
- [x] **Dynamic navigation** (Admin button for logged-in admins, Sign In for guests)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
require "test_helper"
|
||||
|
||||
class Admin::DashboardControllerTest < ActionDispatch::IntegrationTest
|
||||
test "should redirect to login when not authenticated" do
|
||||
get admin_root_path
|
||||
assert_redirected_to login_path
|
||||
follow_redirect!
|
||||
assert_select ".bg-red-50", /You must be logged in/
|
||||
end
|
||||
|
||||
test "should redirect to root when logged in as non-admin" do
|
||||
login_as(users(:contributor_user))
|
||||
get admin_root_path
|
||||
assert_redirected_to root_path
|
||||
assert_equal "You must be an administrator to access this page.", flash[:alert]
|
||||
end
|
||||
|
||||
test "should redirect to root when logged in as reviewer" do
|
||||
login_as(users(:reviewer_user))
|
||||
get admin_root_path
|
||||
assert_redirected_to root_path
|
||||
assert_equal "You must be an administrator to access this page.", flash[:alert]
|
||||
end
|
||||
|
||||
test "should show dashboard when logged in as admin" do
|
||||
login_as(users(:admin_user))
|
||||
get admin_root_path
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "should show admin dashboard path" do
|
||||
login_as(users(:admin_user))
|
||||
get admin_dashboard_path
|
||||
assert_response :success
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,143 @@
|
||||
require "test_helper"
|
||||
|
||||
class Admin::InvitationsControllerTest < ActionDispatch::IntegrationTest
|
||||
test "should redirect to login when not authenticated" do
|
||||
get admin_invitations_path
|
||||
assert_redirected_to login_path
|
||||
end
|
||||
|
||||
test "should redirect to root when logged in as non-admin" do
|
||||
login_as(users(:contributor_user))
|
||||
get admin_invitations_path
|
||||
assert_redirected_to root_path
|
||||
end
|
||||
|
||||
test "should show invitations index when logged in as admin" do
|
||||
login_as(users(:admin_user))
|
||||
get admin_invitations_path
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "should get new invitation page when logged in as admin" do
|
||||
login_as(users(:admin_user))
|
||||
get new_admin_invitation_path
|
||||
assert_response :success
|
||||
assert_select "form"
|
||||
assert_select "input[name='user[email]']"
|
||||
assert_select "input[name='user[name]']"
|
||||
assert_select "select[name='user[role]']"
|
||||
assert_select "select[name='user[primary_language]']"
|
||||
end
|
||||
|
||||
test "should display pending invitations on index page" do
|
||||
login_as(users(:admin_user))
|
||||
get admin_invitations_path
|
||||
assert_response :success
|
||||
assert_select "h1,h2", /Invitations/
|
||||
end
|
||||
|
||||
test "should create invitation when logged in as admin" do
|
||||
login_as(users(:admin_user))
|
||||
|
||||
assert_difference("User.count", 1) do
|
||||
post admin_invitations_path, params: {
|
||||
user: {
|
||||
email: "newuser@example.com",
|
||||
name: "New User",
|
||||
role: "contributor",
|
||||
primary_language: "en"
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
assert_redirected_to admin_invitations_path
|
||||
|
||||
new_user = User.find_by(email: "newuser@example.com")
|
||||
assert_not_nil new_user
|
||||
assert_not_nil new_user.invitation_token
|
||||
assert_not_nil new_user.invitation_sent_at
|
||||
assert_nil new_user.invitation_accepted_at
|
||||
assert_equal users(:admin_user).id, new_user.invited_by_id
|
||||
end
|
||||
|
||||
test "should send invitation email when creating invitation" do
|
||||
login_as(users(:admin_user))
|
||||
|
||||
assert_enqueued_emails 1 do
|
||||
post admin_invitations_path, params: {
|
||||
user: {
|
||||
email: "newuser@example.com",
|
||||
name: "New User",
|
||||
role: "contributor",
|
||||
primary_language: "en"
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
test "should not create invitation with invalid data" do
|
||||
login_as(users(:admin_user))
|
||||
|
||||
assert_no_difference("User.count") do
|
||||
post admin_invitations_path, params: {
|
||||
user: {
|
||||
email: "",
|
||||
name: "New User",
|
||||
role: "contributor",
|
||||
primary_language: "en"
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
end
|
||||
|
||||
test "should cancel pending invitation when logged in as admin" do
|
||||
login_as(users(:admin_user))
|
||||
|
||||
assert_difference("User.count", -1) do
|
||||
delete admin_invitation_path(users(:pending_invitation))
|
||||
end
|
||||
|
||||
assert_redirected_to admin_invitations_path
|
||||
end
|
||||
|
||||
test "should not cancel accepted invitation" do
|
||||
login_as(users(:admin_user))
|
||||
|
||||
assert_no_difference("User.count") do
|
||||
delete admin_invitation_path(users(:contributor_user))
|
||||
end
|
||||
|
||||
assert_redirected_to admin_invitations_path
|
||||
follow_redirect!
|
||||
assert_select ".bg-red-50", /Cannot cancel an accepted invitation/
|
||||
end
|
||||
|
||||
test "should not allow non-admin to create invitation" do
|
||||
login_as(users(:contributor_user))
|
||||
|
||||
assert_no_difference("User.count") do
|
||||
post admin_invitations_path, params: {
|
||||
user: {
|
||||
email: "newuser@example.com",
|
||||
name: "New User",
|
||||
role: "contributor",
|
||||
primary_language: "en"
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
assert_redirected_to root_path
|
||||
end
|
||||
|
||||
test "should not allow non-admin to cancel invitation" do
|
||||
login_as(users(:contributor_user))
|
||||
|
||||
assert_no_difference("User.count") do
|
||||
delete admin_invitation_path(users(:pending_invitation))
|
||||
end
|
||||
|
||||
assert_redirected_to root_path
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,68 @@
|
||||
require "test_helper"
|
||||
|
||||
class Admin::UsersControllerTest < ActionDispatch::IntegrationTest
|
||||
test "should redirect to login when not authenticated" do
|
||||
get admin_users_path
|
||||
assert_redirected_to login_path
|
||||
end
|
||||
|
||||
test "should redirect to root when logged in as non-admin" do
|
||||
login_as(users(:contributor_user))
|
||||
get admin_users_path
|
||||
assert_redirected_to root_path
|
||||
end
|
||||
|
||||
test "should show users index when logged in as admin" do
|
||||
login_as(users(:admin_user))
|
||||
get admin_users_path
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "should get edit page for user when logged in as admin" do
|
||||
login_as(users(:admin_user))
|
||||
get edit_admin_user_path(users(:contributor_user))
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "should update user role when logged in as admin" do
|
||||
login_as(users(:admin_user))
|
||||
|
||||
patch admin_user_path(users(:contributor_user)), params: {
|
||||
user: { role: "reviewer" }
|
||||
}
|
||||
|
||||
assert_redirected_to admin_users_path
|
||||
assert_equal "reviewer", users(:contributor_user).reload.role
|
||||
end
|
||||
|
||||
test "should delete user when logged in as admin" do
|
||||
login_as(users(:admin_user))
|
||||
|
||||
# Delete reviewer_user who has no associated records
|
||||
assert_difference("User.count", -1) do
|
||||
delete admin_user_path(users(:reviewer_user))
|
||||
end
|
||||
|
||||
assert_redirected_to admin_users_path
|
||||
end
|
||||
|
||||
test "should not allow non-admin to update user" do
|
||||
login_as(users(:contributor_user))
|
||||
|
||||
patch admin_user_path(users(:reviewer_user)), params: {
|
||||
user: { role: "admin" }
|
||||
}
|
||||
|
||||
assert_redirected_to root_path
|
||||
end
|
||||
|
||||
test "should not allow non-admin to delete user" do
|
||||
login_as(users(:contributor_user))
|
||||
|
||||
assert_no_difference("User.count") do
|
||||
delete admin_user_path(users(:reviewer_user))
|
||||
end
|
||||
|
||||
assert_redirected_to root_path
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,42 @@
|
||||
require "test_helper"
|
||||
|
||||
class CommentsControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
@user = users(:contributor_user)
|
||||
@entry = entries(:one)
|
||||
@supported_language = supported_languages(:one)
|
||||
end
|
||||
|
||||
test "should not create comment if not logged in" do
|
||||
assert_no_difference("Comment.count") do
|
||||
post entry_comments_url(@entry), params: { comment: { body: "Test comment", language_code: @supported_language.code } }
|
||||
end
|
||||
assert_redirected_to new_user_session_url
|
||||
end
|
||||
|
||||
test "should create comment if logged in" do
|
||||
login_as @user
|
||||
assert_difference("Comment.count", 1) do
|
||||
post entry_comments_url(@entry), params: { comment: { body: "Test comment", language_code: @supported_language.code } }
|
||||
end
|
||||
assert_redirected_to @entry
|
||||
assert_equal "Test comment", Comment.last.body
|
||||
assert_equal @supported_language.code, Comment.last.language_code
|
||||
assert_equal @user, Comment.last.user
|
||||
end
|
||||
|
||||
# Assuming you want to test turbo stream responses as well
|
||||
test "should create comment and respond with turbo stream" do
|
||||
login_as @user
|
||||
post entry_comments_url(@entry), params: { comment: { body: "Test turbo comment", language_code: @supported_language.code } }, as: :turbo_stream
|
||||
assert_response :success
|
||||
assert_match(/turbo-stream action=\"append\" target=\"comments-#{@supported_language.code}\"/, response.body)
|
||||
assert_match(/Test turbo comment/, response.body)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def new_user_session_url
|
||||
login_path
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,93 @@
|
||||
require "test_helper"
|
||||
|
||||
class InvitationsControllerTest < ActionDispatch::IntegrationTest
|
||||
test "should show invitation acceptance page with valid token" do
|
||||
user = users(:pending_invitation)
|
||||
|
||||
get invitation_path(user.invitation_token)
|
||||
|
||||
assert_response :success
|
||||
assert_select "h1", "Accept Invitation"
|
||||
assert_select "input[type=password]", count: 2
|
||||
end
|
||||
|
||||
test "should redirect with invalid token" do
|
||||
get invitation_path("invalid_token")
|
||||
|
||||
assert_redirected_to root_path
|
||||
assert_equal "Invalid or expired invitation link.", flash[:alert]
|
||||
end
|
||||
|
||||
test "should redirect with expired token" do
|
||||
user = users(:pending_invitation)
|
||||
user.update(invitation_sent_at: 15.days.ago)
|
||||
|
||||
get invitation_path(user.invitation_token)
|
||||
|
||||
assert_redirected_to root_path
|
||||
assert_equal "Invalid or expired invitation link.", flash[:alert]
|
||||
end
|
||||
|
||||
test "should accept invitation with valid password" do
|
||||
user = users(:pending_invitation)
|
||||
|
||||
patch accept_invitation_path(user.invitation_token), params: {
|
||||
user: {
|
||||
password: "securepassword123",
|
||||
password_confirmation: "securepassword123"
|
||||
}
|
||||
}
|
||||
|
||||
user.reload
|
||||
assert_not_nil user.invitation_accepted_at
|
||||
assert_nil user.invitation_token
|
||||
assert_equal user.id, session[:user_id]
|
||||
assert_redirected_to root_path
|
||||
end
|
||||
|
||||
test "should not accept invitation with short password" do
|
||||
user = users(:pending_invitation)
|
||||
|
||||
patch accept_invitation_path(user.invitation_token), params: {
|
||||
user: {
|
||||
password: "short",
|
||||
password_confirmation: "short"
|
||||
}
|
||||
}
|
||||
|
||||
user.reload
|
||||
assert_nil user.invitation_accepted_at
|
||||
assert_not_nil user.invitation_token
|
||||
assert_response :unprocessable_entity
|
||||
end
|
||||
|
||||
test "should not accept invitation with mismatched passwords" do
|
||||
user = users(:pending_invitation)
|
||||
|
||||
patch accept_invitation_path(user.invitation_token), params: {
|
||||
user: {
|
||||
password: "securepassword123",
|
||||
password_confirmation: "differentpassword"
|
||||
}
|
||||
}
|
||||
|
||||
user.reload
|
||||
assert_nil user.invitation_accepted_at
|
||||
assert_response :unprocessable_entity
|
||||
end
|
||||
|
||||
test "should not accept expired invitation" do
|
||||
user = users(:pending_invitation)
|
||||
user.update(invitation_sent_at: 15.days.ago)
|
||||
|
||||
patch accept_invitation_path(user.invitation_token), params: {
|
||||
user: {
|
||||
password: "securepassword123",
|
||||
password_confirmation: "securepassword123"
|
||||
}
|
||||
}
|
||||
|
||||
assert_redirected_to root_path
|
||||
assert_equal "Invalid or expired invitation link.", flash[:alert]
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,105 @@
|
||||
require "test_helper"
|
||||
|
||||
class SessionsControllerTest < ActionDispatch::IntegrationTest
|
||||
test "should get login page" do
|
||||
get login_path
|
||||
assert_response :success
|
||||
assert_select "h1", "Sign in"
|
||||
assert_select "input[type=email]"
|
||||
assert_select "input[type=password]"
|
||||
end
|
||||
|
||||
test "should redirect to admin if already logged in as admin" do
|
||||
login_as(users(:admin_user))
|
||||
get login_path
|
||||
assert_redirected_to admin_root_path
|
||||
end
|
||||
|
||||
test "should redirect to root if already logged in as non-admin" do
|
||||
login_as(users(:contributor_user))
|
||||
get login_path
|
||||
assert_redirected_to root_path
|
||||
end
|
||||
|
||||
test "should login with valid credentials" do
|
||||
post login_path, params: {
|
||||
email: "admin@example.com",
|
||||
password: "password123456"
|
||||
}
|
||||
|
||||
assert_redirected_to admin_root_path
|
||||
assert_equal users(:admin_user).id, session[:user_id]
|
||||
follow_redirect!
|
||||
assert_select ".bg-green-50", /Welcome back/
|
||||
end
|
||||
|
||||
test "should login contributor and redirect to root" do
|
||||
post login_path, params: {
|
||||
email: "contributor@example.com",
|
||||
password: "password123456"
|
||||
}
|
||||
|
||||
assert_redirected_to root_path
|
||||
assert_equal users(:contributor_user).id, session[:user_id]
|
||||
end
|
||||
|
||||
test "should not login with invalid email" do
|
||||
post login_path, params: {
|
||||
email: "nonexistent@example.com",
|
||||
password: "password123456"
|
||||
}
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
assert_nil session[:user_id]
|
||||
assert_select ".bg-red-50", /Invalid email or password/
|
||||
end
|
||||
|
||||
test "should not login with invalid password" do
|
||||
post login_path, params: {
|
||||
email: "admin@example.com",
|
||||
password: "wrongpassword"
|
||||
}
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
assert_nil session[:user_id]
|
||||
assert_select ".bg-red-50", /Invalid email or password/
|
||||
end
|
||||
|
||||
test "should handle email with whitespace and case insensitivity" do
|
||||
post login_path, params: {
|
||||
email: " ADMIN@EXAMPLE.COM ",
|
||||
password: "password123456"
|
||||
}
|
||||
|
||||
assert_redirected_to admin_root_path
|
||||
assert_equal users(:admin_user).id, session[:user_id]
|
||||
end
|
||||
|
||||
test "should not login user with pending invitation" do
|
||||
post login_path, params: {
|
||||
email: "pending@example.com",
|
||||
password: "password123456"
|
||||
}
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
assert_nil session[:user_id]
|
||||
assert_select ".bg-red-50", /Your account is pending/
|
||||
end
|
||||
|
||||
test "should logout and redirect to root" do
|
||||
login_as(users(:admin_user))
|
||||
|
||||
delete logout_path
|
||||
|
||||
assert_redirected_to root_path
|
||||
assert_nil session[:user_id]
|
||||
assert_equal "You have been logged out.", flash[:notice]
|
||||
end
|
||||
|
||||
test "should logout even when not logged in" do
|
||||
delete logout_path
|
||||
|
||||
assert_redirected_to root_path
|
||||
assert_nil session[:user_id]
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,100 @@
|
||||
require "test_helper"
|
||||
|
||||
class SetupControllerTest < ActionDispatch::IntegrationTest
|
||||
def setup
|
||||
SetupState.reset!
|
||||
end
|
||||
|
||||
def teardown
|
||||
SetupState.reset!
|
||||
end
|
||||
|
||||
test "should show setup page when not installed" do
|
||||
get setup_path
|
||||
assert_response :success
|
||||
assert_select "h2", /Create Admin Account/
|
||||
end
|
||||
|
||||
test "should redirect to root when already installed" do
|
||||
SetupState.mark_installed!
|
||||
|
||||
get setup_path
|
||||
assert_redirected_to root_path
|
||||
assert_equal "Setup has already been completed.", flash[:alert]
|
||||
end
|
||||
|
||||
test "should create admin user and mark as installed" do
|
||||
assert_difference("User.count", 1) do
|
||||
post setup_path, params: {
|
||||
user: {
|
||||
email: "setupadmin@example.com",
|
||||
name: "Setup Admin",
|
||||
password: "securepassword123",
|
||||
password_confirmation: "securepassword123",
|
||||
primary_language: "en"
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
assert SetupState.installed?
|
||||
|
||||
new_user = User.find_by(email: "setupadmin@example.com")
|
||||
assert_not_nil new_user
|
||||
assert_equal "admin", new_user.role
|
||||
assert_not_nil new_user.invitation_accepted_at
|
||||
assert_equal new_user.id, session[:user_id]
|
||||
|
||||
assert_redirected_to admin_root_path
|
||||
end
|
||||
|
||||
test "should not create user with invalid password" do
|
||||
assert_no_difference("User.count") do
|
||||
post setup_path, params: {
|
||||
user: {
|
||||
email: "setupadmin@example.com",
|
||||
name: "Setup Admin",
|
||||
password: "short", # Too short, minimum is 12
|
||||
password_confirmation: "short",
|
||||
primary_language: "en"
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
assert_not SetupState.installed?
|
||||
assert_response :unprocessable_entity
|
||||
end
|
||||
|
||||
test "should not create user with mismatched passwords" do
|
||||
assert_no_difference("User.count") do
|
||||
post setup_path, params: {
|
||||
user: {
|
||||
email: "setupadmin@example.com",
|
||||
name: "Setup Admin",
|
||||
password: "securepassword123",
|
||||
password_confirmation: "differentpassword",
|
||||
primary_language: "en"
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
assert_not SetupState.installed?
|
||||
assert_response :unprocessable_entity
|
||||
end
|
||||
|
||||
test "should not create user without email" do
|
||||
assert_no_difference("User.count") do
|
||||
post setup_path, params: {
|
||||
user: {
|
||||
email: "",
|
||||
name: "Setup Admin",
|
||||
password: "securepassword123",
|
||||
password_confirmation: "securepassword123",
|
||||
primary_language: "en"
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
assert_not SetupState.installed?
|
||||
assert_response :unprocessable_entity
|
||||
end
|
||||
end
|
||||
Vendored
+2
-2
@@ -1,13 +1,13 @@
|
||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||
|
||||
one:
|
||||
user: one
|
||||
user: admin_user
|
||||
commentable: one
|
||||
commentable_type: Commentable
|
||||
body: MyText
|
||||
|
||||
two:
|
||||
user: two
|
||||
user: contributor_user
|
||||
commentable: two
|
||||
commentable_type: Commentable
|
||||
body: MyText
|
||||
|
||||
Vendored
+4
-4
@@ -10,8 +10,8 @@ one:
|
||||
de: MyString
|
||||
notes: MyText
|
||||
verified: false
|
||||
created_by: one
|
||||
updated_by: one
|
||||
created_by: admin_user
|
||||
updated_by: admin_user
|
||||
|
||||
two:
|
||||
category: 1
|
||||
@@ -23,5 +23,5 @@ two:
|
||||
de: MyString
|
||||
notes: MyText
|
||||
verified: false
|
||||
created_by: two
|
||||
updated_by: two
|
||||
created_by: contributor_user
|
||||
updated_by: contributor_user
|
||||
|
||||
Vendored
-13
@@ -1,13 +0,0 @@
|
||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||
|
||||
one:
|
||||
entry: one
|
||||
user: one
|
||||
changes_made: "{}"
|
||||
change_type: MyString
|
||||
|
||||
two:
|
||||
entry: two
|
||||
user: two
|
||||
changes_made: "{}"
|
||||
change_type: MyString
|
||||
Vendored
+2
-2
@@ -9,7 +9,7 @@ one:
|
||||
source: MyString
|
||||
region: MyString
|
||||
status: :pending
|
||||
submitted_by: one
|
||||
submitted_by: contributor_user
|
||||
|
||||
two:
|
||||
entry: two
|
||||
@@ -20,4 +20,4 @@ two:
|
||||
source: MyString
|
||||
region: MyString
|
||||
status: :pending
|
||||
submitted_by: two
|
||||
submitted_by: contributor_user
|
||||
|
||||
Vendored
+41
-18
@@ -1,23 +1,46 @@
|
||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||
# Password for all test users: "password123456"
|
||||
|
||||
one:
|
||||
email: "one@example.com"
|
||||
password_digest: <%= BCrypt::Password.create('password') %>
|
||||
name: "User One"
|
||||
role: 1
|
||||
admin_user:
|
||||
email: "admin@example.com"
|
||||
password_digest: <%= BCrypt::Password.create('password123456') %>
|
||||
name: "Admin User"
|
||||
role: 2 # admin
|
||||
primary_language: "en"
|
||||
invitation_token: "one"
|
||||
invitation_sent_at: 2026-01-22 13:38:37
|
||||
invitation_accepted_at: 2026-01-22 13:38:37
|
||||
invited_by: one
|
||||
invitation_token: "admin_token_accepted"
|
||||
invitation_sent_at: <%= 30.days.ago %>
|
||||
invitation_accepted_at: <%= 30.days.ago %>
|
||||
invited_by_id: ~
|
||||
|
||||
two:
|
||||
email: "two@example.com"
|
||||
password_digest: <%= BCrypt::Password.create('password') %>
|
||||
name: "User Two"
|
||||
role: 1
|
||||
reviewer_user:
|
||||
email: "reviewer@example.com"
|
||||
password_digest: <%= BCrypt::Password.create('password123456') %>
|
||||
name: "Reviewer User"
|
||||
role: 1 # reviewer
|
||||
primary_language: "en"
|
||||
invitation_token: "reviewer_token_accepted"
|
||||
invitation_sent_at: <%= 20.days.ago %>
|
||||
invitation_accepted_at: <%= 20.days.ago %>
|
||||
invited_by: admin_user
|
||||
|
||||
contributor_user:
|
||||
email: "contributor@example.com"
|
||||
password_digest: <%= BCrypt::Password.create('password123456') %>
|
||||
name: "Contributor User"
|
||||
role: 0 # contributor
|
||||
primary_language: "fi"
|
||||
invitation_token: "two"
|
||||
invitation_sent_at: 2026-01-22 13:38:37
|
||||
invitation_accepted_at: 2026-01-22 13:38:37
|
||||
invited_by: two
|
||||
invitation_token: "contributor_token_accepted"
|
||||
invitation_sent_at: <%= 10.days.ago %>
|
||||
invitation_accepted_at: <%= 10.days.ago %>
|
||||
invited_by: admin_user
|
||||
|
||||
pending_invitation:
|
||||
email: "pending@example.com"
|
||||
password_digest: <%= BCrypt::Password.create('password123456') %>
|
||||
name: "Pending User"
|
||||
role: 0 # contributor
|
||||
primary_language: "en"
|
||||
invitation_token: "pending_token_12345"
|
||||
invitation_sent_at: <%= 2.days.ago %>
|
||||
invitation_accepted_at: ~
|
||||
invited_by: admin_user
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
require "test_helper"
|
||||
|
||||
class InvitationMailerTest < ActionMailer::TestCase
|
||||
test "invite sends email with correct details" do
|
||||
user = users(:pending_invitation)
|
||||
mail = InvitationMailer.invite(user)
|
||||
|
||||
assert_equal "You've been invited to join Sanasto Wiki", mail.subject
|
||||
assert_equal [ user.email ], mail.to
|
||||
assert_match user.name, mail.body.encoded
|
||||
assert_match user.email, mail.body.encoded
|
||||
assert_match user.role.titleize, mail.body.encoded
|
||||
end
|
||||
|
||||
test "invite includes invitation link" do
|
||||
user = users(:pending_invitation)
|
||||
mail = InvitationMailer.invite(user)
|
||||
|
||||
assert_match "invitations/#{user.invitation_token}", mail.body.encoded
|
||||
end
|
||||
|
||||
test "invite includes expiry date" do
|
||||
user = users(:pending_invitation)
|
||||
mail = InvitationMailer.invite(user)
|
||||
|
||||
expires_at = user.invitation_sent_at + User::INVITATION_TOKEN_EXPIRY
|
||||
assert_match expires_at.strftime("%B"), mail.body.encoded
|
||||
end
|
||||
|
||||
test "invite has both HTML and text parts" do
|
||||
user = users(:pending_invitation)
|
||||
mail = InvitationMailer.invite(user)
|
||||
|
||||
assert_equal 2, mail.parts.size
|
||||
assert_equal "text/plain", mail.text_part.content_type.split(";").first
|
||||
assert_equal "text/html", mail.html_part.content_type.split(";").first
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
# Preview all emails at http://localhost:3000/rails/mailers/invitation_mailer
|
||||
class InvitationMailerPreview < ActionMailer::Preview
|
||||
# Preview this email at http://localhost:3000/rails/mailers/invitation_mailer/invite
|
||||
def invite
|
||||
InvitationMailer.invite
|
||||
end
|
||||
end
|
||||
@@ -1,29 +1,41 @@
|
||||
require "test_helper"
|
||||
|
||||
class CommentTest < ActiveSupport::TestCase
|
||||
test "should be valid with a user, body, and commentable" do
|
||||
user = users(:one)
|
||||
test "should be valid with a user, body, commentable, and language code" do
|
||||
user = users(:contributor_user)
|
||||
entry = entries(:one)
|
||||
comment = Comment.new(user: user, body: "This is a comment.", commentable: entry)
|
||||
language = supported_languages(:one)
|
||||
comment = Comment.new(user: user, body: "This is a comment.", commentable: entry, language: language)
|
||||
assert comment.valid?
|
||||
end
|
||||
|
||||
test "should be valid without a language code" do
|
||||
user = users(:contributor_user)
|
||||
entry = entries(:one)
|
||||
comment = Comment.new(user: user, body: "General note.", commentable: entry, language_code: nil)
|
||||
assert comment.valid?
|
||||
end
|
||||
|
||||
test "should be invalid without a body" do
|
||||
user = users(:one)
|
||||
user = users(:contributor_user)
|
||||
entry = entries(:one)
|
||||
comment = Comment.new(user: user, commentable: entry)
|
||||
language = supported_languages(:one)
|
||||
comment = Comment.new(user: user, commentable: entry, language: language)
|
||||
assert_not comment.valid?
|
||||
end
|
||||
|
||||
test "should be invalid without a user" do
|
||||
entry = entries(:one)
|
||||
comment = Comment.new(body: "This is a comment.", commentable: entry)
|
||||
language = supported_languages(:one)
|
||||
comment = Comment.new(body: "This is a comment.", commentable: entry, language: language)
|
||||
assert_not comment.valid?
|
||||
end
|
||||
|
||||
test "should be invalid without a commentable" do
|
||||
user = users(:one)
|
||||
comment = Comment.new(user: user, body: "This is a comment.")
|
||||
user = users(:contributor_user)
|
||||
language = supported_languages(:one)
|
||||
comment = Comment.new(user: user, body: "This is a comment.", language: language)
|
||||
assert_not comment.valid?
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
require "test_helper"
|
||||
|
||||
class EntryVersionTest < ActiveSupport::TestCase
|
||||
test "should be valid with all attributes" do
|
||||
version = EntryVersion.new(
|
||||
entry: entries(:one),
|
||||
user: users(:one),
|
||||
changes_made: { "fi" => "uusi sana" }
|
||||
)
|
||||
assert version.valid?
|
||||
end
|
||||
|
||||
test "should be invalid without changes_made" do
|
||||
version = EntryVersion.new(
|
||||
entry: entries(:one),
|
||||
user: users(:one)
|
||||
)
|
||||
assert_not version.valid?
|
||||
end
|
||||
|
||||
test "should be invalid without an entry" do
|
||||
version = EntryVersion.new(
|
||||
user: users(:one),
|
||||
changes_made: { "fi" => "uusi sana" }
|
||||
)
|
||||
assert_not version.valid?
|
||||
end
|
||||
|
||||
test "should be invalid without a user" do
|
||||
version = EntryVersion.new(
|
||||
entry: entries(:one),
|
||||
changes_made: { "fi" => "uusi sana" }
|
||||
)
|
||||
assert_not version.valid?
|
||||
end
|
||||
end
|
||||
@@ -6,7 +6,7 @@ class SuggestedMeaningTest < ActiveSupport::TestCase
|
||||
entry: entries(:one),
|
||||
language_code: supported_languages(:one).code,
|
||||
alternative_translation: "New Translation",
|
||||
submitted_by: users(:one)
|
||||
submitted_by: users(:contributor_user)
|
||||
)
|
||||
assert meaning.valid?
|
||||
end
|
||||
@@ -15,7 +15,7 @@ class SuggestedMeaningTest < ActiveSupport::TestCase
|
||||
meaning = SuggestedMeaning.new(
|
||||
entry: entries(:one),
|
||||
alternative_translation: "New Translation",
|
||||
submitted_by: users(:one)
|
||||
submitted_by: users(:contributor_user)
|
||||
)
|
||||
assert_not meaning.valid?
|
||||
end
|
||||
@@ -24,7 +24,7 @@ class SuggestedMeaningTest < ActiveSupport::TestCase
|
||||
meaning = SuggestedMeaning.new(
|
||||
entry: entries(:one),
|
||||
language_code: supported_languages(:one).code,
|
||||
submitted_by: users(:one)
|
||||
submitted_by: users(:contributor_user)
|
||||
)
|
||||
assert_not meaning.valid?
|
||||
end
|
||||
@@ -34,7 +34,7 @@ class SuggestedMeaningTest < ActiveSupport::TestCase
|
||||
entry: entries(:one),
|
||||
language_code: supported_languages(:one).code,
|
||||
alternative_translation: "New Translation",
|
||||
submitted_by: users(:one)
|
||||
submitted_by: users(:contributor_user)
|
||||
)
|
||||
assert meaning.pending?
|
||||
end
|
||||
@@ -44,7 +44,7 @@ class SuggestedMeaningTest < ActiveSupport::TestCase
|
||||
entry: entries(:one),
|
||||
language_code: supported_languages(:one).code,
|
||||
alternative_translation: "New Translation",
|
||||
submitted_by: users(:one),
|
||||
submitted_by: users(:contributor_user),
|
||||
status: :accepted
|
||||
)
|
||||
assert meaning.accepted?
|
||||
@@ -55,7 +55,7 @@ class SuggestedMeaningTest < ActiveSupport::TestCase
|
||||
entry: entries(:one),
|
||||
language_code: supported_languages(:one).code,
|
||||
alternative_translation: "New Translation",
|
||||
submitted_by: users(:one),
|
||||
submitted_by: users(:contributor_user),
|
||||
status: :rejected
|
||||
)
|
||||
assert meaning.rejected?
|
||||
|
||||
@@ -2,13 +2,12 @@ require "test_helper"
|
||||
|
||||
class SupportedLanguageTest < ActiveSupport::TestCase
|
||||
test "should be valid with all attributes" do
|
||||
language = SupportedLanguage.new(code: "es", name: "Spanish", native_name: "Español")
|
||||
language = SupportedLanguage.new(code: "sv", name: "Swedish", native_name: "Svenska")
|
||||
assert language.valid?
|
||||
end
|
||||
|
||||
test "should be invalid with a duplicate code" do
|
||||
SupportedLanguage.create(code: "de", name: "German", native_name: "Deutsch")
|
||||
language = SupportedLanguage.new(code: "de", name: "German", native_name: "Deutsch")
|
||||
language = SupportedLanguage.new(code: supported_languages(:one).code, name: "English", native_name: "English")
|
||||
assert_not language.valid?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,7 +2,7 @@ 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: "password123456")
|
||||
user = User.new(email: "new-user@example.com", password: "password123456")
|
||||
assert user.valid?
|
||||
end
|
||||
|
||||
@@ -12,23 +12,23 @@ class UserTest < ActiveSupport::TestCase
|
||||
end
|
||||
|
||||
test "should be invalid with a duplicate email" do
|
||||
User.create(email: "test@example.com", password: "password123456")
|
||||
user = User.new(email: "test@example.com", password: "password123456")
|
||||
existing_user = users(:admin_user)
|
||||
user = User.new(email: existing_user.email, password: "password123456")
|
||||
assert_not user.valid?
|
||||
end
|
||||
|
||||
test "should have a default role of contributor" do
|
||||
user = User.new(email: "test@example.com", password: "password123456")
|
||||
user = User.new(email: "new-user@example.com", password: "password123456")
|
||||
assert user.contributor?
|
||||
end
|
||||
|
||||
test "can be a reviewer" do
|
||||
user = User.new(email: "test@example.com", password: "password123456", role: :reviewer)
|
||||
user = User.new(email: "new-user@example.com", password: "password123456", role: :reviewer)
|
||||
assert user.reviewer?
|
||||
end
|
||||
|
||||
test "can be an admin" do
|
||||
user = User.new(email: "test@example.com", password: "password123456", role: :admin)
|
||||
user = User.new(email: "new-user@example.com", password: "password123456", role: :admin)
|
||||
assert user.admin?
|
||||
end
|
||||
|
||||
|
||||
+19
-2
@@ -4,8 +4,8 @@ require "rails/test_help"
|
||||
|
||||
module ActiveSupport
|
||||
class TestCase
|
||||
# Run tests in parallel with specified workers
|
||||
parallelize(workers: :number_of_processors)
|
||||
# Run tests serially to avoid sqlite/FTS5 conflicts in test setup.
|
||||
parallelize(workers: 1)
|
||||
|
||||
# Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.
|
||||
fixtures :all
|
||||
@@ -13,3 +13,20 @@ module ActiveSupport
|
||||
# Add more helper methods to be used by all tests here...
|
||||
end
|
||||
end
|
||||
|
||||
module ActionDispatch
|
||||
class IntegrationTest
|
||||
# Helper method to login as a user in integration tests
|
||||
def login_as(user, password: "password123456")
|
||||
post login_path, params: {
|
||||
email: user.email,
|
||||
password: password
|
||||
}
|
||||
end
|
||||
|
||||
# Helper method to logout
|
||||
def logout
|
||||
delete logout_path
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user